mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 04:44:30 +00:00
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:
parent
2e203f7355
commit
9d5827b522
@ -10,7 +10,7 @@
|
|||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>org.knoe</groupId>
|
<groupId>org.prole</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>
|
||||||
|
|||||||
@ -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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority;
|
package org.prole.authority;
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.config;
|
package org.prole.authority.config;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -13,7 +13,7 @@ public class AuthProperties {
|
|||||||
private String cookieDomain = ".knoe.dev";
|
private String cookieDomain = ".knoe.dev";
|
||||||
private Duration sessionTtl = Duration.ofHours(8);
|
private Duration sessionTtl = Duration.ofHours(8);
|
||||||
private String sessionSecret = "";
|
private String sessionSecret = "";
|
||||||
private String emailDomain = "knoe.dev";
|
private String emailDomain = "knoey.com";
|
||||||
private boolean formEnabled = false;
|
private boolean formEnabled = false;
|
||||||
private List<String> adminPrincipals = new ArrayList<>();
|
private List<String> adminPrincipals = new ArrayList<>();
|
||||||
private Oidc oidc = new Oidc();
|
private Oidc oidc = new Oidc();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.config;
|
package org.prole.authority.config;
|
||||||
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.kerberos;
|
package org.prole.authority.kerberos;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.kerberos;
|
package org.prole.authority.kerberos;
|
||||||
|
|
||||||
import java.security.PrivilegedExceptionAction;
|
import java.security.PrivilegedExceptionAction;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.session;
|
package org.prole.authority.session;
|
||||||
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
package org.knoe.authority.session;
|
package org.prole.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.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
|
import org.prole.authority.config.KerberosProperties;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
@ -25,12 +26,14 @@ import java.util.UUID;
|
|||||||
public class OidcTokenService {
|
public class OidcTokenService {
|
||||||
|
|
||||||
private final AuthProperties authProperties;
|
private final AuthProperties authProperties;
|
||||||
|
private final KerberosProperties kerberosProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private KeyPair keyPair;
|
private KeyPair keyPair;
|
||||||
private String kid;
|
private String kid;
|
||||||
|
|
||||||
public OidcTokenService(AuthProperties authProperties, ObjectMapper objectMapper) {
|
public OidcTokenService(AuthProperties authProperties, KerberosProperties kerberosProperties, ObjectMapper objectMapper) {
|
||||||
this.authProperties = authProperties;
|
this.authProperties = authProperties;
|
||||||
|
this.kerberosProperties = kerberosProperties;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +86,7 @@ public class OidcTokenService {
|
|||||||
.claim("nonce", nonce)
|
.claim("nonce", nonce)
|
||||||
.claim("preferred_username", user.username())
|
.claim("preferred_username", user.username())
|
||||||
.claim("email", user.email())
|
.claim("email", user.email())
|
||||||
|
.claim("realm", kerberosProperties.getRealm())
|
||||||
.setHeaderParam("kid", kid)
|
.setHeaderParam("kid", kid)
|
||||||
.signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
|
.signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256)
|
||||||
.compact();
|
.compact();
|
||||||
|
|||||||
@ -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.stereotype.Service;
|
||||||
import org.springframework.web.util.WebUtils;
|
import org.springframework.web.util.WebUtils;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.session;
|
package org.prole.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;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.session;
|
package org.prole.authority.session;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.user;
|
package org.prole.authority.user;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import org.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.session.OidcTokenService;
|
import org.prole.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;
|
||||||
@ -19,7 +19,7 @@ public class JwksController {
|
|||||||
this.oidcTokenService = oidcTokenService;
|
this.oidcTokenService = oidcTokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/jwks")
|
@GetMapping("/jwks.json")
|
||||||
public ResponseEntity<Map<String, Object>> getJwks() {
|
public ResponseEntity<Map<String, Object>> getJwks() {
|
||||||
if (!authProperties.getOidc().isEnabled()) {
|
if (!authProperties.getOidc().isEnabled()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -10,13 +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.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.config.KerberosProperties;
|
import org.prole.authority.config.KerberosProperties;
|
||||||
import org.knoe.authority.kerberos.KerberosPasswordService;
|
import org.prole.authority.enroll.GoogleOAuthService;
|
||||||
import org.knoe.authority.kerberos.KerberosSpnegoService;
|
import org.prole.authority.kerberos.KerberosPasswordService;
|
||||||
import org.knoe.authority.session.SessionTokenService;
|
import org.prole.authority.kerberos.KerberosSpnegoService;
|
||||||
import org.knoe.authority.session.SessionUser;
|
import org.prole.authority.session.SessionTokenService;
|
||||||
import org.knoe.authority.user.PrincipalNormalizer;
|
import org.prole.authority.session.SessionUser;
|
||||||
|
import org.prole.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;
|
||||||
@ -37,7 +38,7 @@ public class LoginController {
|
|||||||
private final KerberosPasswordService passwordAuth;
|
private final KerberosPasswordService passwordAuth;
|
||||||
private final PrincipalNormalizer normalizer;
|
private final PrincipalNormalizer normalizer;
|
||||||
private final SessionTokenService sessionTokenService;
|
private final SessionTokenService sessionTokenService;
|
||||||
private final org.prole.authority.enroll.GoogleOAuthService googleOAuth;
|
private final GoogleOAuthService googleOAuth;
|
||||||
|
|
||||||
public LoginController(
|
public LoginController(
|
||||||
AuthProperties auth,
|
AuthProperties auth,
|
||||||
@ -46,7 +47,7 @@ public class LoginController {
|
|||||||
KerberosPasswordService passwordAuth,
|
KerberosPasswordService passwordAuth,
|
||||||
PrincipalNormalizer normalizer,
|
PrincipalNormalizer normalizer,
|
||||||
SessionTokenService sessionTokenService,
|
SessionTokenService sessionTokenService,
|
||||||
org.prole.authority.enroll.GoogleOAuthService googleOAuth
|
GoogleOAuthService googleOAuth
|
||||||
) {
|
) {
|
||||||
this.auth = auth;
|
this.auth = auth;
|
||||||
this.kerberos = kerberos;
|
this.kerberos = kerberos;
|
||||||
@ -195,7 +196,8 @@ public class LoginController {
|
|||||||
var googleId = googleOAuth.exchangeCode(code, redirectUri);
|
var googleId = googleOAuth.exchangeCode(code, redirectUri);
|
||||||
|
|
||||||
String email = googleId.email();
|
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));
|
SessionUser user = new SessionUser(username, email, resolveGroups(username));
|
||||||
setSessionCookie(response, user);
|
setSessionCookie(response, user);
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import org.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.session.OidcCodeService;
|
import org.prole.authority.session.OidcCodeService;
|
||||||
import org.knoe.authority.session.SessionService;
|
import org.prole.authority.session.SessionService;
|
||||||
import org.knoe.authority.session.SessionUser;
|
import org.prole.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;
|
||||||
|
|||||||
@ -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.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;
|
||||||
@ -29,7 +29,7 @@ public class OidcDiscoveryController {
|
|||||||
Map.entry("authorization_endpoint", issuer + "/authorize"),
|
Map.entry("authorization_endpoint", issuer + "/authorize"),
|
||||||
Map.entry("token_endpoint", issuer + "/token"),
|
Map.entry("token_endpoint", issuer + "/token"),
|
||||||
Map.entry("userinfo_endpoint", issuer + "/userinfo"),
|
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("response_types_supported", List.of("code")),
|
||||||
Map.entry("subject_types_supported", List.of("public")),
|
Map.entry("subject_types_supported", List.of("public")),
|
||||||
Map.entry("id_token_signing_alg_values_supported", List.of("RS256")),
|
Map.entry("id_token_signing_alg_values_supported", List.of("RS256")),
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import org.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.session.OidcCodeService;
|
import org.prole.authority.session.OidcCodeService;
|
||||||
import org.knoe.authority.session.OidcTokenService;
|
import org.prole.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;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -28,8 +29,9 @@ public class OidcTokenController {
|
|||||||
@RequestParam("grant_type") String grantType,
|
@RequestParam("grant_type") String grantType,
|
||||||
@RequestParam("code") String code,
|
@RequestParam("code") String code,
|
||||||
@RequestParam("redirect_uri") String redirectUri,
|
@RequestParam("redirect_uri") String redirectUri,
|
||||||
@RequestParam("client_id") String clientId,
|
@RequestParam(value = "client_id", required = false) String clientId,
|
||||||
@RequestParam("client_secret") String clientSecret) {
|
@RequestParam(value = "client_secret", required = false) String clientSecret,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
if (!authProperties.getOidc().isEnabled()) {
|
if (!authProperties.getOidc().isEnabled()) {
|
||||||
return ResponseEntity.status(403).body(Map.of("error", "oidc_disabled"));
|
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"));
|
return ResponseEntity.badRequest().body(Map.of("error", "unsupported_grant_type"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clientId.equals(authProperties.getOidc().getClientId()) ||
|
String effectiveClientId = clientId;
|
||||||
!clientSecret.equals(authProperties.getOidc().getClientSecret())) {
|
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"));
|
return ResponseEntity.status(401).body(Map.of("error", "invalid_client"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import org.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.session.SessionService;
|
import org.prole.authority.session.SessionService;
|
||||||
import org.knoe.authority.session.SessionUser;
|
import org.prole.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;
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.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.knoe.authority.config.AuthProperties;
|
import org.prole.authority.config.AuthProperties;
|
||||||
import org.knoe.authority.session.SessionTokenService;
|
import org.prole.authority.session.SessionTokenService;
|
||||||
import org.knoe.authority.session.SessionUser;
|
import org.prole.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;
|
||||||
|
|||||||
@ -15,7 +15,7 @@ knoe:
|
|||||||
sessionTtl: 8h
|
sessionTtl: 8h
|
||||||
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
|
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
|
||||||
sessionSecret: ""
|
sessionSecret: ""
|
||||||
emailDomain: knoe.dev
|
emailDomain: knoey.com
|
||||||
formEnabled: false
|
formEnabled: false
|
||||||
# Comma-separated list of bare usernames granted admin group membership.
|
# Comma-separated list of bare usernames granted admin group membership.
|
||||||
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
|
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package org.knoe.authority.session;
|
package org.prole.authority.session;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
package org.knoe.authority.web;
|
package org.prole.authority.web;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.knoe.authority.session.SessionTokenService;
|
import org.prole.authority.session.SessionTokenService;
|
||||||
import org.knoe.authority.session.SessionUser;
|
import org.prole.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;
|
||||||
|
|||||||
73
docs/knoe-auth-phase-2.md
Normal file
73
docs/knoe-auth-phase-2.md
Normal 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.
|
||||||
@ -211,8 +211,8 @@ CREATE INDEX IF NOT EXISTS idx_provisioning_job_status
|
|||||||
|
|
||||||
-- Seed well-known knobjects
|
-- Seed well-known knobjects
|
||||||
INSERT INTO knoe.knobject (type, name, metadata) VALUES
|
INSERT INTO knoe.knobject (type, name, metadata) VALUES
|
||||||
('gitea_org', 'knoe.dev', '{"description": "Knoe.DEV Gitea organisation"}'),
|
('gitea_org', 'knoey.com', '{"description": "Knoey.com Gitea organisation"}'),
|
||||||
('gitlab_group','knoe.dev', '{"description": "Knoe.DEV GitLab group"}')
|
('gitlab_group','knoey.com', '{"description": "Knoey.com GitLab group"}')
|
||||||
ON CONFLICT (type, name) DO NOTHING;
|
ON CONFLICT (type, name) DO NOTHING;
|
||||||
|
|
||||||
SELECT 'knoe-auth schema v1 applied.' AS status;
|
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 "Enrollment URL: ${AUTH_HOST}/auth/enroll?token=<invite_token>"
|
||||||
info ""
|
info ""
|
||||||
info "Next: create first admin invite:"
|
info "Next: create first admin invite:"
|
||||||
info " $0 invite chrisfu@prole.org"
|
info " $0 invite chrisfu@knoey.com"
|
||||||
info ""
|
info ""
|
||||||
show_status
|
show_status
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user