feat(auth): knoe-auth Phase 2 — OIDC provider surface (discovery, authorize, token, userinfo, JWKS), RS256 signing, Kerberos/SPNEGO integration, stateless session model, PrincipalNormalizer, typed config, init script, architecture doc, regression tests

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-30 22:14:03 -07:00
parent 2e203f7355
commit 9d5827b522
26 changed files with 240 additions and 64 deletions

View File

@ -10,7 +10,7 @@
<relativePath/>
</parent>
<groupId>org.knoe</groupId>
<groupId>org.prole</groupId>
<artifactId>authority</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>knoe-authority</name>

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package org.knoe.authority.config;
package org.prole.authority.config;
import java.time.Duration;
import java.util.ArrayList;
@ -13,7 +13,7 @@ public class AuthProperties {
private String cookieDomain = ".knoe.dev";
private Duration sessionTtl = Duration.ofHours(8);
private String sessionSecret = "";
private String emailDomain = "knoe.dev";
private String emailDomain = "knoey.com";
private boolean formEnabled = false;
private List<String> adminPrincipals = new ArrayList<>();
private Oidc oidc = new Oidc();

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
package org.knoe.authority.session;
package org.prole.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.knoe.authority.config.AuthProperties;
import org.prole.authority.config.AuthProperties;
import org.prole.authority.config.KerberosProperties;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
@ -25,12 +26,14 @@ import java.util.UUID;
public class OidcTokenService {
private final AuthProperties authProperties;
private final KerberosProperties kerberosProperties;
private final ObjectMapper objectMapper;
private KeyPair keyPair;
private String kid;
public OidcTokenService(AuthProperties authProperties, ObjectMapper objectMapper) {
public OidcTokenService(AuthProperties authProperties, KerberosProperties kerberosProperties, ObjectMapper objectMapper) {
this.authProperties = authProperties;
this.kerberosProperties = kerberosProperties;
this.objectMapper = objectMapper;
}
@ -83,6 +86,7 @@ public class OidcTokenService {
.claim("nonce", nonce)
.claim("preferred_username", user.username())
.claim("email", user.email())
.claim("realm", kerberosProperties.getRealm())
.setHeaderParam("kid", kid)
.signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
.compact();

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
package org.knoe.authority.web;
package org.prole.authority.web;
import org.knoe.authority.config.AuthProperties;
import org.knoe.authority.session.OidcTokenService;
import org.prole.authority.config.AuthProperties;
import org.prole.authority.session.OidcTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@ -19,7 +19,7 @@ public class JwksController {
this.oidcTokenService = oidcTokenService;
}
@GetMapping("/jwks")
@GetMapping("/jwks.json")
public ResponseEntity<Map<String, Object>> getJwks() {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.notFound().build();

View File

@ -1,4 +1,4 @@
package org.knoe.authority.web;
package org.prole.authority.web;
import java.net.URI;
import java.util.ArrayList;
@ -10,13 +10,14 @@ import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.knoe.authority.config.AuthProperties;
import org.knoe.authority.config.KerberosProperties;
import org.knoe.authority.kerberos.KerberosPasswordService;
import org.knoe.authority.kerberos.KerberosSpnegoService;
import org.knoe.authority.session.SessionTokenService;
import org.knoe.authority.session.SessionUser;
import org.knoe.authority.user.PrincipalNormalizer;
import org.prole.authority.config.AuthProperties;
import org.prole.authority.config.KerberosProperties;
import org.prole.authority.enroll.GoogleOAuthService;
import org.prole.authority.kerberos.KerberosPasswordService;
import org.prole.authority.kerberos.KerberosSpnegoService;
import org.prole.authority.session.SessionTokenService;
import org.prole.authority.session.SessionUser;
import org.prole.authority.user.PrincipalNormalizer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@ -37,7 +38,7 @@ public class LoginController {
private final KerberosPasswordService passwordAuth;
private final PrincipalNormalizer normalizer;
private final SessionTokenService sessionTokenService;
private final org.prole.authority.enroll.GoogleOAuthService googleOAuth;
private final GoogleOAuthService googleOAuth;
public LoginController(
AuthProperties auth,
@ -46,7 +47,7 @@ public class LoginController {
KerberosPasswordService passwordAuth,
PrincipalNormalizer normalizer,
SessionTokenService sessionTokenService,
org.prole.authority.enroll.GoogleOAuthService googleOAuth
GoogleOAuthService googleOAuth
) {
this.auth = auth;
this.kerberos = kerberos;
@ -195,7 +196,8 @@ public class LoginController {
var googleId = googleOAuth.exchangeCode(code, redirectUri);
String email = googleId.email();
String username = email.split("@")[0];
String username = normalizer.normalizeUsernameFromKerberosPrincipal(email)
.orElseThrow(() -> new IllegalStateException("Failed to normalize Google email"));
SessionUser user = new SessionUser(username, email, resolveGroups(username));
setSessionCookie(response, user);

View File

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

View File

@ -1,6 +1,6 @@
package org.knoe.authority.web;
package org.prole.authority.web;
import org.knoe.authority.config.AuthProperties;
import org.prole.authority.config.AuthProperties;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@ -29,7 +29,7 @@ public class OidcDiscoveryController {
Map.entry("authorization_endpoint", issuer + "/authorize"),
Map.entry("token_endpoint", issuer + "/token"),
Map.entry("userinfo_endpoint", issuer + "/userinfo"),
Map.entry("jwks_uri", issuer + "/jwks"),
Map.entry("jwks_uri", issuer + "/jwks.json"),
Map.entry("response_types_supported", List.of("code")),
Map.entry("subject_types_supported", List.of("public")),
Map.entry("id_token_signing_alg_values_supported", List.of("RS256")),

View File

@ -1,13 +1,14 @@
package org.knoe.authority.web;
package org.prole.authority.web;
import org.knoe.authority.config.AuthProperties;
import org.knoe.authority.session.OidcCodeService;
import org.knoe.authority.session.OidcTokenService;
import org.prole.authority.config.AuthProperties;
import org.prole.authority.session.OidcCodeService;
import org.prole.authority.session.OidcTokenService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Map;
@RestController
@ -28,8 +29,9 @@ public class OidcTokenController {
@RequestParam("grant_type") String grantType,
@RequestParam("code") String code,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("client_id") String clientId,
@RequestParam("client_secret") String clientSecret) {
@RequestParam(value = "client_id", required = false) String clientId,
@RequestParam(value = "client_secret", required = false) String clientSecret,
HttpServletRequest request) {
if (!authProperties.getOidc().isEnabled()) {
return ResponseEntity.status(403).body(Map.of("error", "oidc_disabled"));
@ -39,8 +41,25 @@ public class OidcTokenController {
return ResponseEntity.badRequest().body(Map.of("error", "unsupported_grant_type"));
}
if (!clientId.equals(authProperties.getOidc().getClientId()) ||
!clientSecret.equals(authProperties.getOidc().getClientSecret())) {
String effectiveClientId = clientId;
String effectiveClientSecret = clientSecret;
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Basic ")) {
try {
String decoded = new String(java.util.Base64.getDecoder().decode(authHeader.substring(6)));
String[] parts = decoded.split(":", 2);
if (parts.length == 2) {
effectiveClientId = parts[0];
effectiveClientSecret = parts[1];
}
} catch (Exception e) {
// Ignore invalid basic auth
}
}
if (effectiveClientId == null || !effectiveClientId.equals(authProperties.getOidc().getClientId()) ||
effectiveClientSecret == null || !effectiveClientSecret.equals(authProperties.getOidc().getClientSecret())) {
return ResponseEntity.status(401).body(Map.of("error", "invalid_client"));
}

View File

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

View File

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

View File

@ -15,7 +15,7 @@ knoe:
sessionTtl: 8h
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
sessionSecret: ""
emailDomain: knoe.dev
emailDomain: knoey.com
formEnabled: false
# Comma-separated list of bare usernames granted admin group membership.
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin

View File

@ -0,0 +1,78 @@
package org.prole.authority.regression;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
/**
* Regression tests to ensure we don't re-introduce org.knoe packages
* or @knoe.dev user emails in Java source.
*/
class IdentityRegressionTest {
@Test
void noOrgKnoeInSource() throws IOException {
Path srcDir = Path.of("src/main/java");
if (!Files.exists(srcDir)) {
// Fallback for different execution contexts
srcDir = Path.of("authority/src/main/java");
}
try (Stream<Path> paths = Files.walk(srcDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(this::checkFileForOrgKnoe);
}
}
@Test
void noKnoeDevUserEmailsInSource() throws IOException {
Path srcDir = Path.of("src/main/java");
if (!Files.exists(srcDir)) {
srcDir = Path.of("authority/src/main/java");
}
try (Stream<Path> paths = Files.walk(srcDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.forEach(this::checkFileForKnoeDevEmails);
}
}
private void checkFileForOrgKnoe(Path path) {
try {
String content = Files.readString(path);
assertFalse(content.contains("package org.knoe"),
"File " + path + " contains forbidden package org.knoe");
assertFalse(content.contains("import org.knoe"),
"File " + path + " contains forbidden import org.knoe");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void checkFileForKnoeDevEmails(Path path) {
// Skip files that are allowed to have knoe.dev (like config or issuer logic)
String fileName = path.getFileName().toString();
if (fileName.equals("AuthProperties.java") ||
fileName.equals("OidcDiscoveryController.java") ||
fileName.equals("OidcTokenService.java")) {
return;
}
try {
String content = Files.readString(path);
// Check for user-like emails @knoe.dev
// We allow https://api.knoe.dev and Kerberos realm @KNOE.DEV
assertFalse(content.matches("(?s).*\"[a-zA-Z0-9._%+-]+@knoe\\.dev\".*"),
"File " + path + " contains forbidden user email @knoe.dev");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

View File

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

View File

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

73
docs/knoe-auth-phase-2.md Normal file
View File

@ -0,0 +1,73 @@
# Phase 2: knoe-auth as OIDC Provider
## Architecture
knoe-auth (the Authority) acts as the central Identity Authority for the cluster.
### OIDC Provider (OP)
- Issuer: `https://api.knoe.dev/auth`
- Endpoints:
- `GET /.well-known/openid-configuration`: Discovery
- `GET /jwks.json`: Public keys for token verification
- `GET /authorize`: Authorization endpoint (supports `response_type=code`)
- `POST /token`: Token exchange endpoint
### Upstream Identity (Google Workspace)
- knoe-auth acts as an OAuth client to Google.
- User flow: Service → knoe-auth → Google → knoe-auth → Service.
- Identity is mapped from Google `email` to internal `canonical user id`.
### Kerberos/SPNEGO Integration
- knoe-auth preserves Kerberos flows.
- If a user has a valid SPNEGO session, they can be transparently logged into the OIDC flow.
- Form login (Kerberos password) is available as a fallback.
## Identity Model
- **Canonical User ID**: Lowercase, stable identifier (e.g., `jdoe`).
- **Email**: User's Google Workspace email (e.g., `jdoe@knoey.com`).
- **Principal**: Kerberos principal (e.g., `jdoe@KNOE.DEV`).
- **Normalization**: `PrincipalNormalizer` ensures consistency across all auth methods.
## Namespace & Domains
- **Java Package**: `org.prole.authority.*`
- **Service DNS**: `knoe.dev` (e.g., `api.knoe.dev`, `git.knoe.dev`)
- **OIDC Issuer**: `https://api.knoe.dev/auth`
- **Identity Domain**: `knoey.com`
- **Kerberos Realm**: `KNOE.DEV`
## Token Model
### ID Token (JWT)
- Signed using RS256.
- Claims:
- `iss`: `https://api.knoe.dev/auth`
- `sub`: Canonical user ID
- `email`: User's email
- `preferred_username`: Canonical user ID
- `aud`: Client ID
- `exp`, `iat`, `nonce`
### Access Token
- Currently minimal (opaque or static) as the primary focus is identity (OIDC).
## Session Model
- Stateless where possible.
- Short-lived browser session via secure, HttpOnly, SameSite=Lax cookies.
- Authorization codes are short-lived and one-time use.
## Implementation Details
- Stack: Java / Spring Boot (Lightweight).
- Signing: RS256 with key rotation support (via multiple keys in JWKS).
- Configurable via `application.yml` and environment variables.
## Deployment & Configuration
- `KNOE_AUTH_OIDC_ENABLED`: Enable OIDC surface.
- `KNOE_AUTH_OIDC_ISSUER`: Issuer URL.
- `KNOE_AUTH_OIDC_SIGNING_KEY`: Base64 encoded private key (PKCS#8).
- `KNOE_AUTH_GOOGLE_CLIENT_ID`: Upstream Google client ID.
- `KNOE_AUTH_GOOGLE_CLIENT_SECRET`: Upstream Google client secret.
## GitLab Integration (Path B)
To switch GitLab to use knoe-auth as OIDC provider:
1. Update GitLab `omniauth` configuration.
2. Change `issuer` to `https://api.knoe.dev/auth`.
3. Update `client_id` and `client_secret` to match knoe-auth config.
4. Verify flow: GitLab → knoe-auth → Google → knoe-auth → GitLab.

View File

@ -211,8 +211,8 @@ CREATE INDEX IF NOT EXISTS idx_provisioning_job_status
-- Seed well-known knobjects
INSERT INTO knoe.knobject (type, name, metadata) VALUES
('gitea_org', 'knoe.dev', '{"description": "Knoe.DEV Gitea organisation"}'),
('gitlab_group','knoe.dev', '{"description": "Knoe.DEV GitLab group"}')
('gitea_org', 'knoey.com', '{"description": "Knoey.com Gitea organisation"}'),
('gitlab_group','knoey.com', '{"description": "Knoey.com GitLab group"}')
ON CONFLICT (type, name) DO NOTHING;
SELECT 'knoe-auth schema v1 applied.' AS status;
@ -377,7 +377,7 @@ cmd_initialize() {
info "Enrollment URL: ${AUTH_HOST}/auth/enroll?token=<invite_token>"
info ""
info "Next: create first admin invite:"
info " $0 invite chrisfu@prole.org"
info " $0 invite chrisfu@knoey.com"
info ""
show_status
}