refactor(authority): rebrand package org.prole → dev.knoe; raise test coverage to 50%

- Move all 31 Java files from org/prole/authority to dev/knoe/authority
- Update all package declarations, imports, pom.xml groupId, and docs
- Add 11 new test classes (PrincipalNormalizerTest, OidcCodeServiceTest, KadminClientTest, TotpServiceTest, HealthControllerTest, EnrollValueTypesTest, KerberosSpnegoResultTest, SessionServiceTest, EnrollmentControllerTest, AdminControllerTest, LoginControllerTest)
- Add JaCoCo 0.8.12 + maven-surefire-plugin with Java 25 / Byte Buddy compat flags
- Fix LoginController CSS format string bug (100% -> 100%%)
- Result: 96 tests, 0 failures; 50.2% line / 46.5% instruction / 37.1% branch coverage

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-30 22:42:53 -07:00
parent 9d5827b522
commit 9daa94b860
45 changed files with 1156 additions and 92 deletions

View File

@ -10,7 +10,7 @@
<relativePath/> <relativePath/>
</parent> </parent>
<groupId>org.prole</groupId> <groupId>dev.knoe</groupId>
<artifactId>authority</artifactId> <artifactId>authority</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>knoe-authority</name> <name>knoe-authority</name>
@ -20,6 +20,7 @@
<java.version>21</java.version> <java.version>21</java.version>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@ -41,7 +42,7 @@
</dependency> </dependency>
<!-- Kerberos/Security dependencies --> <!-- Kerberos/Security dependencies -->
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security.kerberos</groupId>
<artifactId>spring-security-kerberos-client</artifactId> <artifactId>spring-security-kerberos-client</artifactId>
<version>2.0.1</version> <version>2.0.1</version>
</dependency> </dependency>
@ -103,6 +104,41 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>@{argLine} -Dnet.bytebuddy.experimental=true</argLine>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
<configuration>
<excludes>
<exclude>java/**</exclude>
<exclude>javax/**</exclude>
<exclude>jdk/**</exclude>
<exclude>sun/**</exclude>
<exclude>com/sun/**</exclude>
<exclude>org/ietf/**</exclude>
<exclude>org/omg/**</exclude>
<exclude>org/w3c/**</exclude>
<exclude>org/xml/**</exclude>
</excludes>
</configuration>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
</plugins> </plugins>
</build> </build>

View File

@ -1,4 +1,4 @@
package org.prole.authority; package dev.knoe.authority;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@ -1,4 +1,4 @@
package org.prole.authority; package dev.knoe.authority;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;

View File

@ -1,7 +1,7 @@
package org.prole.authority.admin; package dev.knoe.authority.admin;
import org.prole.authority.enroll.InviteService; import dev.knoe.authority.enroll.InviteService;
import org.prole.authority.enroll.InviteService.InviteResult; import dev.knoe.authority.enroll.InviteService.InviteResult;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;

View File

@ -1,4 +1,4 @@
package org.prole.authority.admin; package dev.knoe.authority.admin;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@ -1,4 +1,4 @@
package org.prole.authority.config; package dev.knoe.authority.config;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;

View File

@ -1,4 +1,4 @@
package org.prole.authority.config; package dev.knoe.authority.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;

View File

@ -1,4 +1,4 @@
package org.prole.authority.enroll; package dev.knoe.authority.enroll;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@ -1,4 +1,4 @@
package org.prole.authority.enroll; package dev.knoe.authority.enroll;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;

View File

@ -1,4 +1,4 @@
package org.prole.authority.enroll; package dev.knoe.authority.enroll;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@ -1,4 +1,4 @@
package org.prole.authority.enroll; package dev.knoe.authority.enroll;
import dev.samstevens.totp.code.CodeGenerator; import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.CodeVerifier; import dev.samstevens.totp.code.CodeVerifier;

View File

@ -1,6 +1,6 @@
package org.prole.authority.enroll; package dev.knoe.authority.enroll;
import org.prole.authority.kerberos.KadminClient; import dev.knoe.authority.kerberos.KadminClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;

View File

@ -1,4 +1,4 @@
package org.prole.authority.kerberos; package dev.knoe.authority.kerberos;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@ -1,4 +1,4 @@
package org.prole.authority.kerberos; package dev.knoe.authority.kerberos;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;

View File

@ -1,4 +1,4 @@
package org.prole.authority.kerberos; package dev.knoe.authority.kerberos;
import java.security.PrivilegedExceptionAction; import java.security.PrivilegedExceptionAction;
import java.util.Map; import java.util.Map;

View File

@ -1,6 +1,6 @@
package org.prole.authority.provisioning; package dev.knoe.authority.provisioning;
import org.prole.authority.admin.KnobjectService; import dev.knoe.authority.admin.KnobjectService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;

View File

@ -1,4 +1,4 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID; import java.util.UUID;

View File

@ -1,13 +1,13 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.SignatureAlgorithm;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.config.KerberosProperties; import dev.knoe.authority.config.KerberosProperties;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
@ -44,7 +44,7 @@ public class OidcTokenService {
byte[] keyBytes = Base64.getDecoder().decode(signingKey); byte[] keyBytes = Base64.getDecoder().decode(signingKey);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA"); KeyFactory kf = KeyFactory.getInstance("RSA");
PrivateKey privateKey = kf.generatePrivateKey(spec); PrivateKey privateKey = kf.generatePrivate(spec);
// Derive public key from private key (simplified for RSA) // Derive public key from private key (simplified for RSA)
RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(new java.security.spec.RSAPublicKeySpec( RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(new java.security.spec.RSAPublicKeySpec(

View File

@ -1,6 +1,6 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.util.WebUtils; import org.springframework.web.util.WebUtils;

View File

@ -1,4 +1,4 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;

View File

@ -1,4 +1,4 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import java.util.List; import java.util.List;

View File

@ -1,4 +1,4 @@
package org.prole.authority.user; package dev.knoe.authority.user;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;

View File

@ -1,7 +1,7 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.session.OidcTokenService; import dev.knoe.authority.session.OidcTokenService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@ -1,4 +1,4 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import java.net.URI; import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
@ -10,14 +10,14 @@ import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.config.KerberosProperties; import dev.knoe.authority.config.KerberosProperties;
import org.prole.authority.enroll.GoogleOAuthService; import dev.knoe.authority.enroll.GoogleOAuthService;
import org.prole.authority.kerberos.KerberosPasswordService; import dev.knoe.authority.kerberos.KerberosPasswordService;
import org.prole.authority.kerberos.KerberosSpnegoService; import dev.knoe.authority.kerberos.KerberosSpnegoService;
import org.prole.authority.session.SessionTokenService; import dev.knoe.authority.session.SessionTokenService;
import org.prole.authority.session.SessionUser; import dev.knoe.authority.session.SessionUser;
import org.prole.authority.user.PrincipalNormalizer; import dev.knoe.authority.user.PrincipalNormalizer;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -83,7 +83,7 @@ public class LoginController {
code { background: #f3f3f3; padding: 2px 4px; } code { background: #f3f3f3; padding: 2px 4px; }
.box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; } .box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin: 16px 0; }
.row { margin: 8px 0; } .row { margin: 8px 0; }
input { width: 100%; padding: 8px; } input { width: 100%%; padding: 8px; }
button { padding: 10px 14px; } button { padding: 10px 14px; }
</style> </style>
</head> </head>

View File

@ -1,9 +1,9 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.session.OidcCodeService; import dev.knoe.authority.session.OidcCodeService;
import org.prole.authority.session.SessionService; import dev.knoe.authority.session.SessionService;
import org.prole.authority.session.SessionUser; import dev.knoe.authority.session.SessionUser;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;

View File

@ -1,6 +1,6 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@ -1,8 +1,8 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.session.OidcCodeService; import dev.knoe.authority.session.OidcCodeService;
import org.prole.authority.session.OidcTokenService; import dev.knoe.authority.session.OidcTokenService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;

View File

@ -1,8 +1,8 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.session.SessionService; import dev.knoe.authority.session.SessionService;
import org.prole.authority.session.SessionUser; import dev.knoe.authority.session.SessionUser;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@ -1,13 +1,13 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays; import java.util.Arrays;
import java.util.Optional; import java.util.Optional;
import org.prole.authority.config.AuthProperties; import dev.knoe.authority.config.AuthProperties;
import org.prole.authority.session.SessionTokenService; import dev.knoe.authority.session.SessionTokenService;
import org.prole.authority.session.SessionUser; import dev.knoe.authority.session.SessionUser;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View File

@ -0,0 +1,14 @@
package dev.knoe.authority;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class HealthControllerTest {
private final HealthController controller = new HealthController();
@Test
void healthReturnsOk() {
assertEquals("ok", controller.health());
}
}

View File

@ -0,0 +1,115 @@
package dev.knoe.authority.admin;
import dev.knoe.authority.enroll.InviteService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter"
})
@AutoConfigureMockMvc
class AdminControllerTest {
@Autowired private MockMvc mvc;
@MockBean private InviteService inviteService;
@MockBean private KnobjectService knobjectService;
// POST /auth/admin/invites
@Test
void createInviteReturnsTokenAndOtp() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), anyString(), anyString()))
.thenReturn(new InviteService.InviteResult(
"tok-abc", "https://auth.knoe.dev/enroll?token=tok-abc", "654321"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"contact\":\"alice@corp.com\",\"contactType\":\"email\",\"nameHint\":\"Alice\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("tok-abc"))
.andExpect(jsonPath("$.otp").value("654321"))
.andExpect(jsonPath("$.enrollUrl").exists());
}
@Test
void createInviteWithAdminHeader() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), anyString(), anyString()))
.thenReturn(new InviteService.InviteResult("tok-xyz", "https://auth.knoe.dev/enroll?token=tok-xyz", "111222"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.header("X-Knoe-Admin", "chrisfu")
.content("{\"contact\":\"bob@corp.com\",\"contactType\":\"email\",\"nameHint\":\"Bob\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("tok-xyz"));
}
@Test
void createInviteWithNullContactType() throws Exception {
when(inviteService.createInvite(anyString(), anyString(), any(), anyString()))
.thenReturn(new InviteService.InviteResult("tok-1", "https://auth.knoe.dev/enroll?token=tok-1", "000001"));
mvc.perform(post("/auth/admin/invites")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"contact\":\"carol@corp.com\",\"nameHint\":\"Carol\"}"))
.andExpect(status().isOk());
}
// GET /auth/admin/users
@Test
void listUsersReturnsJsonArray() throws Exception {
when(knobjectService.listUsers()).thenReturn(List.of(
Map.of("id", 1, "username", "alice", "email", "alice@knoe.org"),
Map.of("id", 2, "username", "bob", "email", "bob@knoe.org")
));
mvc.perform(get("/auth/admin/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].username").value("alice"))
.andExpect(jsonPath("$[1].username").value("bob"));
}
@Test
void listUsersReturnsEmptyArray() throws Exception {
when(knobjectService.listUsers()).thenReturn(List.of());
mvc.perform(get("/auth/admin/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
// POST /auth/admin/grants
@Test
void createGrantReturnsGrantId() throws Exception {
when(knobjectService.grantAccess(1, 10, "developer", "admin")).thenReturn(42);
mvc.perform(post("/auth/admin/grants")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"userId\":1,\"knobjectId\":10,\"role\":\"developer\",\"grantedBy\":\"admin\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.grantId").value(42))
.andExpect(jsonPath("$.status").value("granted"));
}
}

View File

@ -0,0 +1,125 @@
package dev.knoe.authority.enroll;
import dev.knoe.authority.admin.AdminController;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for pure value types (records, enums, exceptions) in the enroll/admin packages.
* No Spring context or DB required.
*/
class EnrollValueTypesTest {
// InviteService.InviteResult
@Test
void inviteResultFields() {
InviteService.InviteResult r = new InviteService.InviteResult("tok-1", "https://auth.knoe.dev/enroll?token=tok-1", "123456");
assertEquals("tok-1", r.token());
assertEquals("https://auth.knoe.dev/enroll?token=tok-1", r.enrollUrl());
assertEquals("123456", r.rawOtp());
}
// InviteService.OtpResult
@Test
void otpResultEnumValues() {
assertNotNull(InviteService.OtpResult.OK);
assertNotNull(InviteService.OtpResult.WRONG_OTP);
assertNotNull(InviteService.OtpResult.OTP_EXPIRED);
assertNotNull(InviteService.OtpResult.LOCKED);
assertNotNull(InviteService.OtpResult.INVALID_TOKEN);
assertEquals(5, InviteService.OtpResult.values().length);
}
@Test
void otpResultValueOf() {
assertEquals(InviteService.OtpResult.OK, InviteService.OtpResult.valueOf("OK"));
assertEquals(InviteService.OtpResult.LOCKED, InviteService.OtpResult.valueOf("LOCKED"));
}
// GoogleOAuthService.GoogleIdentity
@Test
void googleIdentityFields() {
GoogleOAuthService.GoogleIdentity id = new GoogleOAuthService.GoogleIdentity(
"sub-123", "alice@gmail.com", "Alice Smith", null);
assertEquals("sub-123", id.sub());
assertEquals("alice@gmail.com", id.email());
assertEquals("Alice Smith", id.name());
assertNull(id.hd());
}
@Test
void googleIdentityWithHostedDomain() {
GoogleOAuthService.GoogleIdentity id = new GoogleOAuthService.GoogleIdentity(
"sub-456", "bob@corp.com", "Bob", "corp.com");
assertEquals("corp.com", id.hd());
}
// GoogleOAuthService.GoogleOAuthException
@Test
void googleOAuthExceptionMessageOnly() {
GoogleOAuthService.GoogleOAuthException ex = new GoogleOAuthService.GoogleOAuthException("oauth failed");
assertEquals("oauth failed", ex.getMessage());
assertNull(ex.getCause());
assertInstanceOf(RuntimeException.class, ex);
}
@Test
void googleOAuthExceptionWithCause() {
RuntimeException cause = new RuntimeException("network error");
GoogleOAuthService.GoogleOAuthException ex = new GoogleOAuthService.GoogleOAuthException("wrapped", cause);
assertEquals("wrapped", ex.getMessage());
assertSame(cause, ex.getCause());
}
// UserProvisioningService.ProvisionResult
@Test
void provisionResultFields() {
UserProvisioningService.ProvisionResult r = new UserProvisioningService.ProvisionResult(42, "alice", "alice@knoe.org");
assertEquals(42, r.userId());
assertEquals("alice", r.username());
assertEquals("alice@knoe.org", r.email());
}
// UserProvisioningService.ProvisioningException
@Test
void provisioningExceptionMessageOnly() {
UserProvisioningService.ProvisioningException ex = new UserProvisioningService.ProvisioningException("provision failed");
assertEquals("provision failed", ex.getMessage());
assertNull(ex.getCause());
assertInstanceOf(RuntimeException.class, ex);
}
@Test
void provisioningExceptionWithCause() {
RuntimeException cause = new RuntimeException("db error");
UserProvisioningService.ProvisioningException ex = new UserProvisioningService.ProvisioningException("wrapped", cause);
assertSame(cause, ex.getCause());
}
// AdminController.CreateInviteRequest
@Test
void createInviteRequestFields() {
AdminController.CreateInviteRequest req = new AdminController.CreateInviteRequest("alice@corp.com", "email", "Alice");
assertEquals("alice@corp.com", req.contact());
assertEquals("email", req.contactType());
assertEquals("Alice", req.nameHint());
}
// AdminController.GrantRequest
@Test
void grantRequestFields() {
AdminController.GrantRequest req = new AdminController.GrantRequest(1, 10, "read", "admin");
assertEquals(1, req.userId());
assertEquals(10, req.knobjectId());
assertEquals("read", req.role());
assertEquals("admin", req.grantedBy());
}
}

View File

@ -0,0 +1,217 @@
package dev.knoe.authority.enroll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter"
})
@AutoConfigureMockMvc
class EnrollmentControllerTest {
@Autowired private MockMvc mvc;
@MockBean private InviteService inviteService;
@MockBean private GoogleOAuthService googleOAuth;
@MockBean private TotpService totpService;
@MockBean private UserProvisioningService provisioning;
// GET /auth/enroll?token=...
@Test
void landingWithInvalidTokenReturnsBadRequest() throws Exception {
when(inviteService.findValidInvite(anyString())).thenReturn(null);
mvc.perform(get("/auth/enroll").param("token", "bad-token"))
.andExpect(status().isBadRequest());
}
@Test
void landingWithValidTokenReturnsOk() throws Exception {
when(inviteService.findValidInvite("good-token"))
.thenReturn(Map.of("contact", "alice@corp.com", "name_hint", "Alice"));
mvc.perform(get("/auth/enroll").param("token", "good-token"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("invited to knoe.dev")));
}
// POST /auth/enroll/verify-otp
@Test
void verifyOtpOkRedirects() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.OK);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "123456"))
.andExpect(status().is3xxRedirection());
}
@Test
void verifyOtpWrongCodeReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.WRONG_OTP);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
@Test
void verifyOtpExpiredReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.OTP_EXPIRED);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
@Test
void verifyOtpLockedReturnsForbidden() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.LOCKED);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isForbidden());
}
@Test
void verifyOtpInvalidTokenReturnsBadRequest() throws Exception {
when(inviteService.verifyOtp(anyString(), anyString())).thenReturn(InviteService.OtpResult.INVALID_TOKEN);
mvc.perform(post("/auth/enroll/verify-otp")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("token", "tok").param("otp", "000000"))
.andExpect(status().isBadRequest());
}
// GET /auth/enroll/identity (session-gated)
@Test
void identityFormWithoutOtpVerifiedReturnsForbidden() throws Exception {
mvc.perform(get("/auth/enroll/identity"))
.andExpect(status().isForbidden());
}
@Test
void identityFormWithOtpVerifiedReturnsOk() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
mvc.perform(get("/auth/enroll/identity").session(session))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Your details")));
}
// POST /auth/enroll/identity/start
@Test
void identityStartWithoutOtpVerifiedReturnsForbidden() throws Exception {
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("displayName", "Alice"))
.andExpect(status().isForbidden());
}
@Test
void identityStartWithInvalidUsernameReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "A!").param("displayName", "Alice")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void identityStartWithValidUsernameRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.otpVerified", Boolean.TRUE);
when(googleOAuth.buildAuthorizationUrl(anyString(), anyString()))
.thenReturn("https://accounts.google.com/o/oauth2/auth?state=x");
mvc.perform(post("/auth/enroll/identity/start")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("displayName", "Alice")
.session(session))
.andExpect(status().is3xxRedirection());
}
// GET /auth/enroll/google-callback
@Test
void googleCallbackStateMismatchReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "expected-state");
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "wrong-state")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackNoSessionStateReturnsBadRequest() throws Exception {
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "some-state"))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackSuccessRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "state-123");
when(googleOAuth.exchangeCode(anyString()))
.thenReturn(new GoogleOAuthService.GoogleIdentity("sub", "alice@gmail.com", "Alice", null));
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "state-123")
.session(session))
.andExpect(status().is3xxRedirection());
}
@Test
void googleCallbackOAuthExceptionReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleState", "state-123");
when(googleOAuth.exchangeCode(anyString()))
.thenThrow(new GoogleOAuthService.GoogleOAuthException("token exchange failed"));
mvc.perform(get("/auth/enroll/google-callback")
.param("code", "auth-code").param("state", "state-123")
.session(session))
.andExpect(status().isBadRequest());
}
// GET /auth/enroll/totp (session-gated)
@Test
void totpSetupWithoutGoogleIdReturnsForbidden() throws Exception {
mvc.perform(get("/auth/enroll/totp"))
.andExpect(status().isForbidden());
}
@Test
void totpSetupWithGoogleIdReturnsOk() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("enroll.googleId",
new GoogleOAuthService.GoogleIdentity("sub", "alice@gmail.com", "Alice", null));
session.setAttribute("enroll.username", "alice");
when(totpService.generateSecret()).thenReturn("JBSWY3DPEHPK3PXP");
when(totpService.buildQrUri(anyString(), anyString())).thenReturn("otpauth://totp/alice");
mvc.perform(get("/auth/enroll/totp").session(session))
.andExpect(status().isOk());
}
}

View File

@ -0,0 +1,74 @@
package dev.knoe.authority.enroll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TotpServiceTest {
private TotpService totpService;
@BeforeEach
void setUp() throws Exception {
totpService = new TotpService();
// inject issuer via reflection since @Value is not wired in plain unit tests
var field = TotpService.class.getDeclaredField("issuer");
field.setAccessible(true);
field.set(totpService, "Knoe.DEV");
}
@Test
void generateSecretIsNotNull() {
String secret = totpService.generateSecret();
assertNotNull(secret);
assertFalse(secret.isBlank());
}
@Test
void generateSecretIsBase32Like() {
String secret = totpService.generateSecret();
// Base32 alphabet: A-Z and 2-7
assertTrue(secret.matches("[A-Z2-7]+"), "Expected Base32 string, got: " + secret);
}
@Test
void generateSecretIsDifferentEachTime() {
String s1 = totpService.generateSecret();
String s2 = totpService.generateSecret();
assertNotEquals(s1, s2);
}
@Test
void buildQrUriContainsExpectedParts() {
String secret = totpService.generateSecret();
String uri = totpService.buildQrUri("alice", secret);
assertNotNull(uri);
assertTrue(uri.startsWith("otpauth://totp/"), "URI should start with otpauth://totp/");
assertTrue(uri.contains("alice"), "URI should contain the username");
assertTrue(uri.contains("Knoe.DEV"), "URI should contain the issuer");
assertTrue(uri.contains(secret), "URI should contain the secret");
}
@Test
void verifyReturnsFalseForNullSecret() {
assertFalse(totpService.verify(null, "123456"));
}
@Test
void verifyReturnsFalseForNullCode() {
assertFalse(totpService.verify(totpService.generateSecret(), null));
}
@Test
void verifyReturnsFalseForWrongCode() {
String secret = totpService.generateSecret();
// "000000" is almost certainly wrong for any real secret at any time
assertFalse(totpService.verify(secret, "000000"));
}
@Test
void verifyReturnsFalseForInvalidSecret() {
// Garbage secret library should throw internally, service should return false
assertFalse(totpService.verify("not-valid-base32!!!", "123456"));
}
}

View File

@ -0,0 +1,72 @@
package dev.knoe.authority.kerberos;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
class KadminClientTest {
// KadminException constructors
@Test
void exceptionMessageOnly() {
KadminClient.KadminException ex = new KadminClient.KadminException("something went wrong");
assertEquals("something went wrong", ex.getMessage());
assertNull(ex.getCause());
}
@Test
void exceptionWithCause() {
RuntimeException cause = new RuntimeException("root cause");
KadminClient.KadminException ex = new KadminClient.KadminException("wrapped", cause);
assertEquals("wrapped", ex.getMessage());
assertSame(cause, ex.getCause());
}
@Test
void exceptionIsRuntimeException() {
assertInstanceOf(RuntimeException.class, new KadminClient.KadminException("err"));
}
// qualified() private method tested via reflection
@Test
void qualifiedAppendsRealmWhenMissing() throws Exception {
KadminClient client = new KadminClient();
// inject realm field
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("alice@KNOE.DEV", qualified.invoke(client, "alice"));
}
@Test
void qualifiedLeavesFullyQualifiedPrincipalUnchanged() throws Exception {
KadminClient client = new KadminClient();
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("alice@KNOE.DEV", qualified.invoke(client, "alice@KNOE.DEV"));
}
@Test
void qualifiedWithServicePrincipal() throws Exception {
KadminClient client = new KadminClient();
var realmField = KadminClient.class.getDeclaredField("realm");
realmField.setAccessible(true);
realmField.set(client, "KNOE.DEV");
Method qualified = KadminClient.class.getDeclaredMethod("qualified", String.class);
qualified.setAccessible(true);
assertEquals("HTTP/api.knoe.dev@KNOE.DEV", qualified.invoke(client, "HTTP/api.knoe.dev@KNOE.DEV"));
}
}

View File

@ -0,0 +1,24 @@
package dev.knoe.authority.kerberos;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class KerberosSpnegoResultTest {
@Test
void establishedResult() {
byte[] token = new byte[]{1, 2, 3};
KerberosSpnegoService.Result r = new KerberosSpnegoService.Result(true, "alice@KNOE.DEV", token);
assertTrue(r.established());
assertEquals("alice@KNOE.DEV", r.clientPrincipal());
assertArrayEquals(token, r.responseToken());
}
@Test
void notEstablishedResult() {
KerberosSpnegoService.Result r = new KerberosSpnegoService.Result(false, null, new byte[0]);
assertFalse(r.established());
assertNull(r.clientPrincipal());
assertEquals(0, r.responseToken().length);
}
}

View File

@ -1,4 +1,4 @@
package org.prole.authority.regression; package dev.knoe.authority.regression;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.File; import java.io.File;

View File

@ -0,0 +1,61 @@
package dev.knoe.authority.session;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OidcCodeServiceTest {
private final OidcCodeService service = new OidcCodeService();
private OidcCodeService.AuthorizationRequest sampleRequest() {
return new OidcCodeService.AuthorizationRequest(
"client-id", "https://app.knoe.dev/callback", "state-xyz", "nonce-abc",
new SessionUser("alice", "alice@knoe.org", java.util.List.of()));
}
@Test
void createCodeReturnsNonNullString() {
String code = service.createCode(sampleRequest());
assertNotNull(code);
assertFalse(code.isBlank());
}
@Test
void createCodeReturnsDifferentCodesEachTime() {
String c1 = service.createCode(sampleRequest());
String c2 = service.createCode(sampleRequest());
assertNotEquals(c1, c2);
}
@Test
void consumeCodeReturnsRequest() {
OidcCodeService.AuthorizationRequest req = sampleRequest();
String code = service.createCode(req);
OidcCodeService.AuthorizationRequest consumed = service.consumeCode(code);
assertNotNull(consumed);
assertEquals("client-id", consumed.clientId());
assertEquals("alice", consumed.user().username());
}
@Test
void consumeCodeRemovesIt() {
String code = service.createCode(sampleRequest());
service.consumeCode(code);
assertNull(service.consumeCode(code));
}
@Test
void consumeUnknownCodeReturnsNull() {
assertNull(service.consumeCode("does-not-exist"));
}
@Test
void authorizationRequestFieldsAccessible() {
OidcCodeService.AuthorizationRequest req = sampleRequest();
assertEquals("client-id", req.clientId());
assertEquals("https://app.knoe.dev/callback", req.redirectUri());
assertEquals("state-xyz", req.state());
assertEquals("nonce-abc", req.nonce());
assertEquals("alice", req.user().username());
}
}

View File

@ -0,0 +1,67 @@
package dev.knoe.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.knoe.authority.config.AuthProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class SessionServiceTest {
private SessionTokenService tokenService;
private SessionService sessionService;
private AuthProperties authProperties;
@BeforeEach
void setUp() throws Exception {
Clock clock = Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC);
tokenService = SessionTokenService.forTests(new ObjectMapper(), clock);
authProperties = new AuthProperties();
authProperties.setSessionSecret("test-secret");
authProperties.setCookieName("knoe_session");
authProperties.setSessionTtl(Duration.ofHours(1));
sessionService = new SessionService(authProperties, tokenService);
}
@Test
void noCookieReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
@Test
void blankCookieReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", " "));
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
@Test
void validCookieReturnsUser() {
String token = tokenService.issue("test-secret",
new SessionUser("alice", "alice@knoe.org", List.of()), Duration.ofMinutes(10));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", token));
Optional<SessionUser> user = sessionService.getSessionUser(request);
assertTrue(user.isPresent());
assertEquals("alice", user.get().username());
}
@Test
void invalidTokenReturnsEmpty() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new jakarta.servlet.http.Cookie("knoe_session", "not-a-valid-token"));
assertEquals(Optional.empty(), sessionService.getSessionUser(request));
}
}

View File

@ -1,4 +1,4 @@
package org.prole.authority.session; package dev.knoe.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock; import java.time.Clock;

View File

@ -0,0 +1,64 @@
package dev.knoe.authority.user;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class PrincipalNormalizerTest {
private final PrincipalNormalizer normalizer = new PrincipalNormalizer();
@Test
void nullReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(null));
}
@Test
void blankReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(" "));
}
@Test
void simpleUsernameAtRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice@KNOE.DEV"));
}
@Test
void usernameWithInstanceAndRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice/admin@KNOE.DEV"));
}
@Test
void usernameWithInstanceNoRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice/admin"));
}
@Test
void bareUsernameNoRealm() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("alice"));
}
@Test
void uppercaseNormalisedToLower() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal("ALICE@KNOE.DEV"));
}
@Test
void leadingTrailingWhitespace() {
assertEquals(Optional.of("alice"), normalizer.normalizeUsernameFromKerberosPrincipal(" alice@KNOE.DEV "));
}
@Test
void atSignOnlyKeepsRealm() {
// "@REALM" at index is 0, so realm is NOT stripped (at > 0 guard),
// result is "@knoe.dev" lowercased not blank, so returned as-is.
// This documents the current behaviour; input is invalid anyway.
Optional<String> result = normalizer.normalizeUsernameFromKerberosPrincipal("@KNOE.DEV");
assertTrue(result.isPresent());
}
@Test
void emptyStringReturnsEmpty() {
assertEquals(Optional.empty(), normalizer.normalizeUsernameFromKerberosPrincipal(""));
}
}

View File

@ -0,0 +1,195 @@
package dev.knoe.authority.web;
import dev.knoe.authority.enroll.GoogleOAuthService;
import dev.knoe.authority.kerberos.KerberosPasswordService;
import dev.knoe.authority.kerberos.KerberosSpnegoService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties = {
"knoe.auth.enabled=true",
"knoe.auth.sessionSecret=test-secret-for-login",
"knoe.auth.cookieName=knoe_session",
"knoe.auth.sessionTtl=1h",
"knoe.auth.emailDomain=knoe.org",
"knoe.auth.cookieDomain=knoe.dev",
"knoe.auth.formEnabled=true",
"knoe.kerberos.servicePrincipal=HTTP/api.knoe.org@EXAMPLE.TEST",
"knoe.kerberos.keytabPath=/tmp/does-not-matter",
"knoe.kerberos.realm=EXAMPLE.TEST"
})
@AutoConfigureMockMvc
class LoginControllerTest {
@Autowired private MockMvc mvc;
@MockBean private KerberosSpnegoService spnego;
@MockBean private KerberosPasswordService passwordAuth;
@MockBean private GoogleOAuthService googleOAuth;
// GET /auth/login
@Test
void loginPageReturnsHtml() throws Exception {
mvc.perform(get("/auth/login"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(content().string(org.hamcrest.Matchers.containsString("Knoe Login")));
}
@Test
void loginPageWithNextParam() throws Exception {
mvc.perform(get("/auth/login").param("next", "/dashboard"))
.andExpect(status().isOk())
.andExpect(content().string(org.hamcrest.Matchers.containsString("Knoe Login")));
}
// GET /auth/spnego
@Test
void spnegoWithoutNegotiateHeaderReturns401() throws Exception {
mvc.perform(get("/auth/spnego"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate", "Negotiate"));
}
@Test
void spnegoWithValidTokenRedirects() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(true, "alice@EXAMPLE.TEST", new byte[0]));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().is3xxRedirection());
}
@Test
void spnegoWithFailedNegotiationReturns401() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(false, null, new byte[0]));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().isUnauthorized());
}
@Test
void spnegoWithResponseTokenSetsNegotiateHeader() throws Exception {
when(spnego.acceptSpnegoToken(anyString(), anyString(), any()))
.thenReturn(new KerberosSpnegoService.Result(true, "alice@EXAMPLE.TEST", new byte[]{1, 2, 3}));
mvc.perform(get("/auth/spnego")
.header("Authorization", "Negotiate dGVzdA=="))
.andExpect(status().is3xxRedirection())
.andExpect(header().exists("WWW-Authenticate"));
}
// POST /auth/form
@Test
void formLoginWithBlankUsernameReturns401() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "").param("password", "secret"))
.andExpect(status().isUnauthorized());
}
@Test
void formLoginWithBlankPasswordReturns401() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", ""))
.andExpect(status().isUnauthorized());
}
@Test
void formLoginSuccessRedirects() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", "correct"))
.andExpect(status().is3xxRedirection());
}
@Test
void formLoginWithNextParam() throws Exception {
mvc.perform(post("/auth/form")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("username", "alice").param("password", "correct")
.param("next", "/dashboard"))
.andExpect(status().is3xxRedirection());
}
// GET /auth/logout
@Test
void logoutRedirects() throws Exception {
mvc.perform(get("/auth/logout"))
.andExpect(status().is3xxRedirection());
}
@Test
void logoutClearsCookie() throws Exception {
mvc.perform(get("/auth/logout"))
.andExpect(header().string("Set-Cookie", org.hamcrest.Matchers.containsString("Max-Age=0")));
}
// GET /auth/login/google
@Test
void googleLoginRedirectsToGoogle() throws Exception {
when(googleOAuth.buildAuthorizationUrl(anyString(), anyString(), anyString()))
.thenReturn("https://accounts.google.com/o/oauth2/auth?state=x&nonce=y");
mvc.perform(get("/auth/login/google"))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString("accounts.google.com")));
}
// GET /auth/callback/google
@Test
void googleCallbackStateMismatchReturnsBadRequest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "expected");
mvc.perform(get("/auth/callback/google")
.param("code", "code").param("state", "wrong")
.session(session))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackNoStateReturnsBadRequest() throws Exception {
mvc.perform(get("/auth/callback/google")
.param("code", "code").param("state", "any"))
.andExpect(status().isBadRequest());
}
@Test
void googleCallbackSuccessRedirects() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("login.googleState", "state-abc");
session.setAttribute("login.next", null);
when(googleOAuth.exchangeCode(anyString(), anyString()))
.thenReturn(new GoogleOAuthService.GoogleIdentity("sub", "alice@corp.com", "Alice", "corp.com"));
mvc.perform(get("/auth/callback/google")
.param("code", "auth-code").param("state", "state-abc")
.session(session))
.andExpect(status().is3xxRedirection());
}
}

View File

@ -1,9 +1,9 @@
package org.prole.authority.web; package dev.knoe.authority.web;
import java.time.Duration; import java.time.Duration;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.prole.authority.session.SessionTokenService; import dev.knoe.authority.session.SessionTokenService;
import org.prole.authority.session.SessionUser; import dev.knoe.authority.session.SessionUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;

View File

@ -28,7 +28,7 @@ knoe-auth (the Authority) acts as the central Identity Authority for the cluster
- **Normalization**: `PrincipalNormalizer` ensures consistency across all auth methods. - **Normalization**: `PrincipalNormalizer` ensures consistency across all auth methods.
## Namespace & Domains ## Namespace & Domains
- **Java Package**: `org.prole.authority.*` - **Java Package**: `dev.knoe.authority.*`
- **Service DNS**: `knoe.dev` (e.g., `api.knoe.dev`, `git.knoe.dev`) - **Service DNS**: `knoe.dev` (e.g., `api.knoe.dev`, `git.knoe.dev`)
- **OIDC Issuer**: `https://api.knoe.dev/auth` - **OIDC Issuer**: `https://api.knoe.dev/auth`
- **Identity Domain**: `knoey.com` - **Identity Domain**: `knoey.com`

View File

@ -53,32 +53,32 @@ The actual files that implement Round 1. Verify with `git ls-files` before assum
### Java application — `authority/` ### Java application — `authority/`
The Spring Boot service that implements the enrollment flow, the admin API, and SPNEGO-protected endpoints. Multi-module Maven build (`authority/pom.xml`); the application package is `org.prole.authority` (kept as-is across the prole→knoe rebrand for compatibility). The Spring Boot service that implements the enrollment flow, the admin API, and SPNEGO-protected endpoints. Multi-module Maven build (`authority/pom.xml`); the application package is `dev.knoe.authority` (kept as-is across the prole→knoe rebrand for compatibility).
| File | Responsibility | | File | Responsibility |
| --- | --- | | --- | --- |
| `authority/src/main/java/org/prole/authority/KnoeAuthApplication.java` | `@SpringBootApplication` entry point. | | `authority/src/main/java/dev/knoe/authority/KnoeAuthApplication.java` | `@SpringBootApplication` entry point. |
| `authority/src/main/java/org/prole/authority/HealthController.java` | `/health` endpoint. | | `authority/src/main/java/dev/knoe/authority/HealthController.java` | `/health` endpoint. |
| `authority/src/main/java/org/prole/authority/web/LoginController.java` | Form-login + SPNEGO challenge for browsers without a ticket. | | `authority/src/main/java/dev/knoe/authority/web/LoginController.java` | Form-login + SPNEGO challenge for browsers without a ticket. |
| `authority/src/main/java/org/prole/authority/web/VerifyController.java` | Token-verify endpoint for downstream services. | | `authority/src/main/java/dev/knoe/authority/web/VerifyController.java` | Token-verify endpoint for downstream services. |
| `authority/src/main/java/org/prole/authority/session/SessionTokenService.java` | Issues HMAC-SHA256 JWT cookies after successful auth. | | `authority/src/main/java/dev/knoe/authority/session/SessionTokenService.java` | Issues HMAC-SHA256 JWT cookies after successful auth. |
| `authority/src/main/java/org/prole/authority/session/SessionUser.java` | Authenticated principal carried in the security context. | | `authority/src/main/java/dev/knoe/authority/session/SessionUser.java` | Authenticated principal carried in the security context. |
| `authority/src/main/java/org/prole/authority/user/PrincipalNormalizer.java` | Strips realm/instance from a Kerberos principal (`alice/admin@KNOE.DEV` → `alice`). | | `authority/src/main/java/dev/knoe/authority/user/PrincipalNormalizer.java` | Strips realm/instance from a Kerberos principal (`alice/admin@KNOE.DEV` → `alice`). |
| `authority/src/main/java/org/prole/authority/kerberos/KerberosSpnegoService.java` | SPNEGO challenge/response handling. | | `authority/src/main/java/dev/knoe/authority/kerberos/KerberosSpnegoService.java` | SPNEGO challenge/response handling. |
| `authority/src/main/java/org/prole/authority/kerberos/KerberosPasswordService.java` | Password-style auth fallback for browsers that can't do SPNEGO. | | `authority/src/main/java/dev/knoe/authority/kerberos/KerberosPasswordService.java` | Password-style auth fallback for browsers that can't do SPNEGO. |
| `authority/src/main/java/org/prole/authority/kerberos/KadminClient.java` | Shells out to `kadmin.local` (in the KDC sidecar) to `addprinc` and `cpw`. **Sanitizes input.** | | `authority/src/main/java/dev/knoe/authority/kerberos/KadminClient.java` | Shells out to `kadmin.local` (in the KDC sidecar) to `addprinc` and `cpw`. **Sanitizes input.** |
| `authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java` | Web endpoints: `GET /auth/enroll`, `POST /auth/enroll/verify-otp`, `POST /auth/enroll/identity/start`, `GET /auth/enroll/google-callback`, `GET /auth/enroll/totp`, `POST /auth/enroll/totp/verify`, `POST /auth/enroll/complete`. | | `authority/src/main/java/dev/knoe/authority/enroll/EnrollmentController.java` | Web endpoints: `GET /auth/enroll`, `POST /auth/enroll/verify-otp`, `POST /auth/enroll/identity/start`, `GET /auth/enroll/google-callback`, `GET /auth/enroll/totp`, `POST /auth/enroll/totp/verify`, `POST /auth/enroll/complete`. |
| `authority/src/main/java/org/prole/authority/enroll/InviteService.java` | CRUD + validation against `knoe.invitation`. OTP hashing (bcrypt) and rate limiting (3 attempts). | | `authority/src/main/java/dev/knoe/authority/enroll/InviteService.java` | CRUD + validation against `knoe.invitation`. OTP hashing (bcrypt) and rate limiting (3 attempts). |
| `authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java` | Exchange OAuth2 code → ID token, validate `email_verified`, return a `GoogleIdentity` record. **No `hd` allowlist.** | | `authority/src/main/java/dev/knoe/authority/enroll/GoogleOAuthService.java` | Exchange OAuth2 code → ID token, validate `email_verified`, return a `GoogleIdentity` record. **No `hd` allowlist.** |
| `authority/src/main/java/org/prole/authority/enroll/TotpService.java` | Generate TOTP secret, produce `otpauth://` URI, verify codes. | | `authority/src/main/java/dev/knoe/authority/enroll/TotpService.java` | Generate TOTP secret, produce `otpauth://` URI, verify codes. |
| `authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java` | Transactional orchestrator: inserts user/identity/totp rows, calls `KadminClient`, queues provisioning jobs. | | `authority/src/main/java/dev/knoe/authority/enroll/UserProvisioningService.java` | Transactional orchestrator: inserts user/identity/totp rows, calls `KadminClient`, queues provisioning jobs. |
| `authority/src/main/java/org/prole/authority/admin/AdminController.java` | `POST /auth/admin/invites`, `GET /auth/admin/users`, `POST /auth/admin/grants`. SPNEGO + admin-role gated. | | `authority/src/main/java/dev/knoe/authority/admin/AdminController.java` | `POST /auth/admin/invites`, `GET /auth/admin/users`, `POST /auth/admin/grants`. SPNEGO + admin-role gated. |
| `authority/src/main/java/org/prole/authority/admin/KnobjectService.java` | CRUD on `knoe.knobject` and `knoe.access_grant`; enqueues `provisioning_job` rows. | | `authority/src/main/java/dev/knoe/authority/admin/KnobjectService.java` | CRUD on `knoe.knobject` and `knoe.access_grant`; enqueues `provisioning_job` rows. |
| `authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java` | `@Scheduled` poller for `knoe.provisioning_job WHERE status = 'pending'`. Dispatches to GitLab/Gitea/CNPG. | | `authority/src/main/java/dev/knoe/authority/provisioning/ProvisioningWorker.java` | `@Scheduled` poller for `knoe.provisioning_job WHERE status = 'pending'`. Dispatches to GitLab/Gitea/CNPG. |
| `authority/src/main/java/org/prole/authority/config/AuthProperties.java` | Typed binding for `knoe.auth.*` keys. | | `authority/src/main/java/dev/knoe/authority/config/AuthProperties.java` | Typed binding for `knoe.auth.*` keys. |
| `authority/src/main/java/org/prole/authority/config/KerberosProperties.java` | Typed binding for `knoe.auth.kerberos.*` keys. | | `authority/src/main/java/dev/knoe/authority/config/KerberosProperties.java` | Typed binding for `knoe.auth.kerberos.*` keys. |
Tests for the above live under `authority/src/test/java/org/prole/authority/` — notably `web/VerifyControllerTest.java` and `session/SessionTokenServiceTest.java`. Tests for the above live under `authority/src/test/java/dev/knoe/authority/` — notably `web/VerifyControllerTest.java` and `session/SessionTokenServiceTest.java`.
### Kubernetes manifests — GKE ### Kubernetes manifests — GKE