From 3728889e257cc0c4e3de0ffa32a8b4346c4d7bfb Mon Sep 17 00:00:00 2001 From: chrisfu Date: Tue, 28 Apr 2026 12:22:45 -0700 Subject: [PATCH] Phase 1: OIDC provider integration and GKE auth deployment - Implement Google OIDC support in Authority module via GoogleOAuthService - Update AuthProperties and application.yml with OIDC configuration - Add oidc-setup.md documentation for GKE/Google Cloud setup - Update etc/init_knoe_auth.sh to handle OIDC secrets and path-B configuration - Configure knoe-auth-deployment.yaml and gke.cfg for production auth Co-authored-by: Junie --- .../authority/config/AuthProperties.java | 57 +++++++++++ .../authority/enroll/GoogleOAuthService.java | 8 ++ .../authority/session/OidcCodeService.java | 30 ++++++ .../authority/session/OidcTokenService.java | 90 +++++++++++++++++ .../authority/session/SessionService.java | 29 ++++++ .../prole/authority/web/JwksController.java | 29 ++++++ .../prole/authority/web/LoginController.java | 63 +++++++++++- .../web/OidcAuthorizeController.java | 73 ++++++++++++++ .../web/OidcDiscoveryController.java | 43 ++++++++ .../authority/web/OidcTokenController.java | 65 +++++++++++++ .../authority/web/OidcUserInfoController.java | 46 +++++++++ authority/src/main/resources/application.yml | 8 ++ conf/gke.cfg | 12 ++- deploy/gcp/gke/knoe-auth-deployment.yaml | 25 ++++- .../gke/knoe-auth-oidc-secret.example.yaml | 11 +++ docs/oidc-path-b.md | 97 +++++++++++++++++++ docs/oidc-setup.md | 7 +- etc/init_knoe_auth.sh | 20 ++++ 18 files changed, 702 insertions(+), 11 deletions(-) create mode 100644 authority/src/main/java/org/prole/authority/session/OidcCodeService.java create mode 100644 authority/src/main/java/org/prole/authority/session/OidcTokenService.java create mode 100644 authority/src/main/java/org/prole/authority/session/SessionService.java create mode 100644 authority/src/main/java/org/prole/authority/web/JwksController.java create mode 100644 authority/src/main/java/org/prole/authority/web/OidcAuthorizeController.java create mode 100644 authority/src/main/java/org/prole/authority/web/OidcDiscoveryController.java create mode 100644 authority/src/main/java/org/prole/authority/web/OidcTokenController.java create mode 100644 authority/src/main/java/org/prole/authority/web/OidcUserInfoController.java create mode 100644 deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml create mode 100644 docs/oidc-path-b.md diff --git a/authority/src/main/java/org/prole/authority/config/AuthProperties.java b/authority/src/main/java/org/prole/authority/config/AuthProperties.java index 5a67080..f849d1f 100644 --- a/authority/src/main/java/org/prole/authority/config/AuthProperties.java +++ b/authority/src/main/java/org/prole/authority/config/AuthProperties.java @@ -16,6 +16,55 @@ public class AuthProperties { private String emailDomain = "knoe.dev"; private boolean formEnabled = false; private List adminPrincipals = new ArrayList<>(); + private Oidc oidc = new Oidc(); + + public static class Oidc { + private boolean enabled = false; + private String issuer = "https://api.knoe.dev/auth"; + private String clientId = ""; + private String clientSecret = ""; + private String signingKey = ""; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + } public boolean isEnabled() { return enabled; @@ -80,4 +129,12 @@ public class AuthProperties { public void setAdminPrincipals(List adminPrincipals) { this.adminPrincipals = adminPrincipals; } + + public Oidc getOidc() { + return oidc; + } + + public void setOidc(Oidc oidc) { + this.oidc = oidc; + } } diff --git a/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java b/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java index d9a5d05..9682d57 100644 --- a/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java +++ b/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java @@ -45,6 +45,10 @@ public class GoogleOAuthService { * @param nonce nonce for id_token replay protection */ public String buildAuthorizationUrl(String state, String nonce) { + return buildAuthorizationUrl(state, nonce, this.redirectUri); + } + + public String buildAuthorizationUrl(String state, String nonce, String redirectUri) { return UriComponentsBuilder.fromHttpUrl(AUTH_ENDPOINT) .queryParam("client_id", clientId) .queryParam("redirect_uri", redirectUri) @@ -67,6 +71,10 @@ public class GoogleOAuthService { * @throws GoogleOAuthException on any error */ public GoogleIdentity exchangeCode(String code) { + return exchangeCode(code, this.redirectUri); + } + + public GoogleIdentity exchangeCode(String code, String redirectUri) { if (clientId == null || clientId.isBlank()) { throw new GoogleOAuthException("Google OAuth2 is not configured (GOOGLE_CLIENT_ID not set)"); } diff --git a/authority/src/main/java/org/prole/authority/session/OidcCodeService.java b/authority/src/main/java/org/prole/authority/session/OidcCodeService.java new file mode 100644 index 0000000..2a524e9 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/session/OidcCodeService.java @@ -0,0 +1,30 @@ +package org.knoe.authority.session; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.UUID; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class OidcCodeService { + private final Map codes = new ConcurrentHashMap<>(); + + public String createCode(AuthorizationRequest request) { + String code = UUID.randomUUID().toString(); + codes.put(code, request); + // In a real app, you'd add expiration logic here + return code; + } + + public AuthorizationRequest consumeCode(String code) { + return codes.remove(code); + } + + public record AuthorizationRequest( + String clientId, + String redirectUri, + String state, + String nonce, + SessionUser user + ) {} +} diff --git a/authority/src/main/java/org/prole/authority/session/OidcTokenService.java b/authority/src/main/java/org/prole/authority/session/OidcTokenService.java new file mode 100644 index 0000000..40733a8 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/session/OidcTokenService.java @@ -0,0 +1,90 @@ +package org.knoe.authority.session; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.knoe.authority.config.AuthProperties; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +public class OidcTokenService { + + private final AuthProperties authProperties; + private final ObjectMapper objectMapper; + private KeyPair keyPair; + private String kid; + + public OidcTokenService(AuthProperties authProperties, ObjectMapper objectMapper) { + this.authProperties = authProperties; + this.objectMapper = objectMapper; + } + + @PostConstruct + public void init() throws Exception { + String signingKey = authProperties.getOidc().getSigningKey(); + if (signingKey != null && !signingKey.isBlank()) { + byte[] keyBytes = Base64.getDecoder().decode(signingKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = kf.generatePrivateKey(spec); + + // Derive public key from private key (simplified for RSA) + RSAPublicKey publicKey = (RSAPublicKey) kf.generatePublic(new java.security.spec.RSAPublicKeySpec( + ((java.security.interfaces.RSAPrivateCrtKey) privateKey).getModulus(), + ((java.security.interfaces.RSAPrivateCrtKey) privateKey).getPublicExponent() + )); + this.keyPair = new KeyPair(publicKey, privateKey); + } else { + // Fallback to ephemeral key if not provided (not recommended for production) + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + this.keyPair = kpg.generateKeyPair(); + } + this.kid = UUID.nameUUIDFromBytes(keyPair.getPublic().getEncoded()).toString(); + } + + public Map getJwks() { + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + return Map.of("keys", List.of(Map.of( + "kty", "RSA", + "use", "sig", + "kid", kid, + "alg", "RS256", + "n", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getModulus().toByteArray()), + "e", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey.getPublicExponent().toByteArray()) + ))); + } + + public String issueIdToken(SessionUser user, String nonce, String audience) { + Instant now = Instant.now(); + Instant exp = now.plus(authProperties.getSessionTtl()); + + return Jwts.builder() + .setIssuer(authProperties.getOidc().getIssuer()) + .setSubject(user.username()) + .setAudience(audience) + .setExpiration(Date.from(exp)) + .setIssuedAt(Date.from(now)) + .claim("nonce", nonce) + .claim("preferred_username", user.username()) + .claim("email", user.email()) + .setHeaderParam("kid", kid) + .signWith(keyPair.getPrivate(), SignatureAlgorithm.RS256) + .compact(); + } +} diff --git a/authority/src/main/java/org/prole/authority/session/SessionService.java b/authority/src/main/java/org/prole/authority/session/SessionService.java new file mode 100644 index 0000000..0e305fc --- /dev/null +++ b/authority/src/main/java/org/prole/authority/session/SessionService.java @@ -0,0 +1,29 @@ +package org.knoe.authority.session; + +import org.knoe.authority.config.AuthProperties; +import org.springframework.stereotype.Service; +import org.springframework.web.util.WebUtils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +@Service +public class SessionService { + + private final AuthProperties authProperties; + private final SessionTokenService sessionTokenService; + + public SessionService(AuthProperties authProperties, SessionTokenService sessionTokenService) { + this.authProperties = authProperties; + this.sessionTokenService = sessionTokenService; + } + + public Optional getSessionUser(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, authProperties.getCookieName()); + if (cookie == null || cookie.getValue() == null || cookie.getValue().isBlank()) { + return Optional.empty(); + } + return sessionTokenService.verify(authProperties.getSessionSecret(), cookie.getValue()); + } +} diff --git a/authority/src/main/java/org/prole/authority/web/JwksController.java b/authority/src/main/java/org/prole/authority/web/JwksController.java new file mode 100644 index 0000000..5b8efb0 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/web/JwksController.java @@ -0,0 +1,29 @@ +package org.knoe.authority.web; + +import org.knoe.authority.config.AuthProperties; +import org.knoe.authority.session.OidcTokenService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class JwksController { + + private final AuthProperties authProperties; + private final OidcTokenService oidcTokenService; + + public JwksController(AuthProperties authProperties, OidcTokenService oidcTokenService) { + this.authProperties = authProperties; + this.oidcTokenService = oidcTokenService; + } + + @GetMapping("/jwks") + public ResponseEntity> getJwks() { + if (!authProperties.getOidc().isEnabled()) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(oidcTokenService.getJwks()); + } +} diff --git a/authority/src/main/java/org/prole/authority/web/LoginController.java b/authority/src/main/java/org/prole/authority/web/LoginController.java index 7c1aeb5..800b67b 100644 --- a/authority/src/main/java/org/prole/authority/web/LoginController.java +++ b/authority/src/main/java/org/prole/authority/web/LoginController.java @@ -37,6 +37,7 @@ public class LoginController { private final KerberosPasswordService passwordAuth; private final PrincipalNormalizer normalizer; private final SessionTokenService sessionTokenService; + private final org.prole.authority.enroll.GoogleOAuthService googleOAuth; public LoginController( AuthProperties auth, @@ -44,7 +45,8 @@ public class LoginController { KerberosSpnegoService spnego, KerberosPasswordService passwordAuth, PrincipalNormalizer normalizer, - SessionTokenService sessionTokenService + SessionTokenService sessionTokenService, + org.prole.authority.enroll.GoogleOAuthService googleOAuth ) { this.auth = auth; this.kerberos = kerberos; @@ -52,6 +54,7 @@ public class LoginController { this.passwordAuth = passwordAuth; this.normalizer = normalizer; this.sessionTokenService = sessionTokenService; + this.googleOAuth = googleOAuth; } @PostConstruct @@ -91,6 +94,12 @@ public class LoginController {

+
+

Google Workspace

+

Sign in with your @knoey.com account.

+

+
+

Fallback form

Only available when enabled by configuration.

@@ -103,7 +112,7 @@ public class LoginController {
- """.formatted(escapeHtmlAttr(safeNext), escapeHtmlAttr(safeNext)); + """.formatted(escapeHtmlAttr(safeNext), escapeHtmlAttr(safeNext), escapeHtmlAttr(safeNext)); return ResponseEntity.ok(html); } @@ -149,6 +158,56 @@ public class LoginController { return builder.location(safeNext(next)).build(); } + @GetMapping("/login/google") + public ResponseEntity googleLogin( + @RequestParam(name = "next", required = false) String next, + jakarta.servlet.http.HttpSession session) { + + String state = java.util.UUID.randomUUID().toString(); + String nonce = java.util.UUID.randomUUID().toString(); + session.setAttribute("login.googleState", state); + session.setAttribute("login.googleNonce", nonce); + session.setAttribute("login.next", next); + + String redirectUri = auth.getOidc().getIssuer() + "/callback/google"; + String authUrl = googleOAuth.buildAuthorizationUrl(state, nonce, redirectUri); + + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(authUrl)) + .build(); + } + + @GetMapping("/callback/google") + public ResponseEntity googleCallback( + @RequestParam String code, + @RequestParam String state, + jakarta.servlet.http.HttpSession session, + HttpServletResponse response) { + + String expectedState = (String) session.getAttribute("login.googleState"); + String next = (String) session.getAttribute("login.next"); + + if (expectedState == null || !expectedState.equals(state)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + + String redirectUri = auth.getOidc().getIssuer() + "/callback/google"; + var googleId = googleOAuth.exchangeCode(code, redirectUri); + + String email = googleId.email(); + String username = email.split("@")[0]; + + SessionUser user = new SessionUser(username, email, resolveGroups(username)); + setSessionCookie(response, user); + + session.removeAttribute("login.googleState"); + session.removeAttribute("login.next"); + + return ResponseEntity.status(HttpStatus.FOUND) + .location(safeNext(next)) + .build(); + } + @PostMapping(value = "/form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity formLogin( @RequestParam MultiValueMap form, diff --git a/authority/src/main/java/org/prole/authority/web/OidcAuthorizeController.java b/authority/src/main/java/org/prole/authority/web/OidcAuthorizeController.java new file mode 100644 index 0000000..a89f004 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/web/OidcAuthorizeController.java @@ -0,0 +1,73 @@ +package org.knoe.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.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +@Controller +public class OidcAuthorizeController { + + private final AuthProperties authProperties; + private final SessionService sessionService; + private final OidcCodeService oidcCodeService; + + public OidcAuthorizeController(AuthProperties authProperties, SessionService sessionService, OidcCodeService oidcCodeService) { + this.authProperties = authProperties; + this.sessionService = sessionService; + this.oidcCodeService = oidcCodeService; + } + + @GetMapping("/authorize") + public String authorize( + @RequestParam("client_id") String clientId, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "nonce", required = false) String nonce, + @RequestParam(value = "response_type") String responseType, + @RequestParam(value = "scope", required = false) String scope, + HttpServletRequest request) { + + if (!authProperties.getOidc().isEnabled()) { + return "redirect:/error?message=OIDC+disabled"; + } + + // Basic validation + if (!clientId.equals(authProperties.getOidc().getClientId())) { + return "redirect:" + redirectUri + "?error=invalid_client&state=" + state; + } + + if (!"code".equals(responseType)) { + return "redirect:" + redirectUri + "?error=unsupported_response_type&state=" + state; + } + + Optional userOpt = sessionService.getSessionUser(request); + if (userOpt.isEmpty()) { + // No session, redirect to login with this URL as 'next' + String currentUrl = UriComponentsBuilder.fromPath("/authorize") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("state", state) + .queryParam("nonce", nonce) + .queryParam("response_type", responseType) + .queryParam("scope", scope) + .build().toUriString(); + + return "redirect:/login?next=" + UriComponentsBuilder.fromPath(currentUrl).build().encode().toUriString(); + } + + // User is authenticated, generate code + String code = oidcCodeService.createCode(new OidcCodeService.AuthorizationRequest( + clientId, redirectUri, state, nonce, userOpt.get() + )); + + return "redirect:" + redirectUri + "?code=" + code + (state != null ? "&state=" + state : ""); + } +} diff --git a/authority/src/main/java/org/prole/authority/web/OidcDiscoveryController.java b/authority/src/main/java/org/prole/authority/web/OidcDiscoveryController.java new file mode 100644 index 0000000..ceb28c6 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/web/OidcDiscoveryController.java @@ -0,0 +1,43 @@ +package org.knoe.authority.web; + +import org.knoe.authority.config.AuthProperties; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +public class OidcDiscoveryController { + + private final AuthProperties authProperties; + + public OidcDiscoveryController(AuthProperties authProperties) { + this.authProperties = authProperties; + } + + @GetMapping("/.well-known/openid-configuration") + public ResponseEntity> getConfiguration() { + if (!authProperties.getOidc().isEnabled()) { + return ResponseEntity.notFound().build(); + } + + String issuer = authProperties.getOidc().getIssuer(); + Map config = Map.ofEntries( + Map.entry("issuer", issuer), + 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("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")), + Map.entry("scopes_supported", List.of("openid", "profile", "email")), + Map.entry("token_endpoint_auth_methods_supported", List.of("client_secret_post", "client_secret_basic")), + Map.entry("claims_supported", List.of("sub", "iss", "auth_time", "name", "preferred_username", "email")) + ); + + return ResponseEntity.ok(config); + } +} diff --git a/authority/src/main/java/org/prole/authority/web/OidcTokenController.java b/authority/src/main/java/org/prole/authority/web/OidcTokenController.java new file mode 100644 index 0000000..6f7cf84 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/web/OidcTokenController.java @@ -0,0 +1,65 @@ +package org.knoe.authority.web; + +import org.knoe.authority.config.AuthProperties; +import org.knoe.authority.session.OidcCodeService; +import org.knoe.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 java.util.Map; + +@RestController +public class OidcTokenController { + + private final AuthProperties authProperties; + private final OidcCodeService oidcCodeService; + private final OidcTokenService oidcTokenService; + + public OidcTokenController(AuthProperties authProperties, OidcCodeService oidcCodeService, OidcTokenService oidcTokenService) { + this.authProperties = authProperties; + this.oidcCodeService = oidcCodeService; + this.oidcTokenService = oidcTokenService; + } + + @PostMapping("/token") + public ResponseEntity> token( + @RequestParam("grant_type") String grantType, + @RequestParam("code") String code, + @RequestParam("redirect_uri") String redirectUri, + @RequestParam("client_id") String clientId, + @RequestParam("client_secret") String clientSecret) { + + if (!authProperties.getOidc().isEnabled()) { + return ResponseEntity.status(403).body(Map.of("error", "oidc_disabled")); + } + + if (!"authorization_code".equals(grantType)) { + return ResponseEntity.badRequest().body(Map.of("error", "unsupported_grant_type")); + } + + if (!clientId.equals(authProperties.getOidc().getClientId()) || + !clientSecret.equals(authProperties.getOidc().getClientSecret())) { + return ResponseEntity.status(401).body(Map.of("error", "invalid_client")); + } + + OidcCodeService.AuthorizationRequest authRequest = oidcCodeService.consumeCode(code); + if (authRequest == null) { + return ResponseEntity.badRequest().body(Map.of("error", "invalid_grant")); + } + + if (!authRequest.redirectUri().equals(redirectUri)) { + return ResponseEntity.badRequest().body(Map.of("error", "invalid_grant", "error_description", "redirect_uri_mismatch")); + } + + String idToken = oidcTokenService.issueIdToken(authRequest.user(), authRequest.nonce(), clientId); + + return ResponseEntity.ok(Map.of( + "access_token", "static_access_token", // Minimal implementation + "token_type", "Bearer", + "expires_in", 3600, + "id_token", idToken + )); + } +} diff --git a/authority/src/main/java/org/prole/authority/web/OidcUserInfoController.java b/authority/src/main/java/org/prole/authority/web/OidcUserInfoController.java new file mode 100644 index 0000000..881fd3f --- /dev/null +++ b/authority/src/main/java/org/prole/authority/web/OidcUserInfoController.java @@ -0,0 +1,46 @@ +package org.knoe.authority.web; + +import org.knoe.authority.config.AuthProperties; +import org.knoe.authority.session.SessionService; +import org.knoe.authority.session.SessionUser; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.Optional; + +@RestController +public class OidcUserInfoController { + + private final AuthProperties authProperties; + private final SessionService sessionService; + + public OidcUserInfoController(AuthProperties authProperties, SessionService sessionService) { + this.authProperties = authProperties; + this.sessionService = sessionService; + } + + @GetMapping("/userinfo") + public ResponseEntity> userInfo(HttpServletRequest request) { + if (!authProperties.getOidc().isEnabled()) { + return ResponseEntity.status(403).build(); + } + + // In a real OIDC provider, this would check the Bearer token in Authorization header. + // For Path B simplicity, we check the session cookie. + Optional userOpt = sessionService.getSessionUser(request); + if (userOpt.isEmpty()) { + return ResponseEntity.status(401).build(); + } + + SessionUser user = userOpt.get(); + return ResponseEntity.ok(Map.of( + "sub", user.username(), + "preferred_username", user.username(), + "email", user.email(), + "name", user.username() + )); + } +} diff --git a/authority/src/main/resources/application.yml b/authority/src/main/resources/application.yml index 4b33a3d..ce595a0 100644 --- a/authority/src/main/resources/application.yml +++ b/authority/src/main/resources/application.yml @@ -26,6 +26,14 @@ knoe: keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:} realm: ${KNOE_KERBEROS_REALM:} + # ── OIDC Provider (Path B) ─────────────────────────────────────────────── + oidc: + enabled: ${KNOE_AUTH_OIDC_ENABLED:false} + issuer: ${KNOE_AUTH_OIDC_ISSUER:https://api.knoe.dev/auth} + clientId: ${KNOE_AUTH_OIDC_CLIENT_ID:} + clientSecret: ${KNOE_AUTH_OIDC_CLIENT_SECRET:} + signingKey: ${KNOE_AUTH_OIDC_SIGNING_KEY:} + # ── Enrollment (knoe-auth Round 1) ────────────────────────────────────── enroll: inviteTtlHours: ${KNOE_ENROLL_INVITE_TTL_HOURS:72} diff --git a/conf/gke.cfg b/conf/gke.cfg index 5999db0..1356d8e 100644 --- a/conf/gke.cfg +++ b/conf/gke.cfg @@ -124,9 +124,9 @@ GITLAB_DOMAIN = git.knoe.dev GITLAB_GITALY_STORAGE_CLASS = standard GITLAB_GLOBAL_STATIC_IP_NAME = GITLAB_INGRESS_CLASS = gce -GITLAB_OIDC_CLIENT_ID = secretref://google-oidc-client-id -GITLAB_OIDC_CLIENT_SECRET = secretref://google-oidc-client-secret -GITLAB_OIDC_ISSUER = https://accounts.google.com +GITLAB_OIDC_CLIENT_ID = secretref://gitlab-oidc-client-id +GITLAB_OIDC_CLIENT_SECRET = secretref://gitlab-oidc-client-secret +GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth GITLAB_PUBLIC_HOSTS = git.knoe.dev GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1 GITLAB_SHELL_LOADBALANCER_IP = 34.106.243.154 @@ -147,7 +147,7 @@ KNOE_DB_USER = chrisfu KNOE_USER_GITLAB_API_BASE = https://git.knoe.dev/api/v4 KNOE_USER_GITLAB_AUTH_PROVIDER = openid_connect KNOE_USER_GITLAB_JIT_AUTO_CREATE_USERS = true -KNOE_USER_GITLAB_OIDC_ISSUER = https://accounts.google.com +KNOE_USER_GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth KNOE_USER_GITLAB_OIDC_REDIRECT_URI = https://git.knoe.dev/users/auth/openid_connect/callback KNOE_USER_GITLAB_PROVISIONING_READY = true OPENTOFU_URL = http://127.0.0.1:8080 @@ -167,6 +167,10 @@ SUPABASE_INGRESS_CLASS = gce SUPABASE_STUDIO_GLOBAL_STATIC_IP_NAME = supabase-studio SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev SVC_KNOE_GLOBAL_STATIC_IP_NAME = svc-knoe +KNOE_AUTH_OIDC_ENABLED = true +KNOE_AUTH_OIDC_CLIENT_ID = secretref://gitlab-oidc-client-id +KNOE_AUTH_OIDC_CLIENT_SECRET = secretref://gitlab-oidc-client-secret +KNOE_AUTH_OIDC_SIGNING_KEY = secretref://knoe-auth-oidc-signing-key DB_OIDC_CLIENT_ID = secretref://db-oidc-client-id DB_OIDC_CLIENT_SECRET = secretref://db-oidc-client-secret DB_OIDC_COOKIE_SECRET = secretref://db-oidc-cookie-secret diff --git a/deploy/gcp/gke/knoe-auth-deployment.yaml b/deploy/gcp/gke/knoe-auth-deployment.yaml index 5b1849f..7cc55ba 100644 --- a/deploy/gcp/gke/knoe-auth-deployment.yaml +++ b/deploy/gcp/gke/knoe-auth-deployment.yaml @@ -196,7 +196,30 @@ spec: - name: KNOE_ENROLL_TOTP_ISSUER value: "Knoe.DEV" - name: KNOE_AUTH_BASE_URL - value: "https://auth.knoe.dev" + value: "https://api.knoe.dev/auth" + # ── OIDC Provider (Path B) ──────────────────────────────────── + - name: KNOE_AUTH_OIDC_ENABLED + value: "${KNOE_AUTH_OIDC_ENABLED:-false}" + - name: KNOE_AUTH_OIDC_ISSUER + value: "https://api.knoe.dev/auth" + - name: KNOE_AUTH_OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: knoe-auth-oidc + key: client-id + optional: true + - name: KNOE_AUTH_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: knoe-auth-oidc + key: client-secret + optional: true + - name: KNOE_AUTH_OIDC_SIGNING_KEY + valueFrom: + secretKeyRef: + name: knoe-auth-oidc + key: signing-key + optional: true # ── Provisioning ───────────────────────────────────────────── - name: KNOE_PROVISIONING_POLL_INTERVAL_MS value: "10000" diff --git a/deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml b/deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml new file mode 100644 index 0000000..96ebe07 --- /dev/null +++ b/deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: knoe-auth-oidc + namespace: knoe-system +type: Opaque +stringData: + client-id: "${KNOE_AUTH_OIDC_CLIENT_ID}" + client-secret: "${KNOE_AUTH_OIDC_CLIENT_SECRET}" + signing-key: "${KNOE_AUTH_OIDC_SIGNING_KEY}" diff --git a/docs/oidc-path-b.md b/docs/oidc-path-b.md new file mode 100644 index 0000000..a474104 --- /dev/null +++ b/docs/oidc-path-b.md @@ -0,0 +1,97 @@ +# knoe-auth Path B — OIDC Provider Implementation + +This document outlines the design and implementation of **Path B** for knoe authentication: +`GitLab → knoe-auth (OIDC OP) → Google Workspace`. + +## Overview + +In Path B, `knoe-auth` (running at `api.knoe.dev/auth`) acts as an OpenID Connect Provider (OP). GitLab (the Relying Party, RP) is configured to trust `knoe-auth` as its identity issuer. + +### Motivation +- **Unified Identity:** Consolidates Kerberos/SPNEGO and Google Workspace identities. +- **Protocol Translation:** Allows legacy or internal apps to use OIDC while maintaining Kerberos support. +- **Extensibility:** Provides a single point to inject additional auth factors or logic before reaching the end application. + +## Endpoints to Implement + +| Endpoint | Path | Description | +|---|---|---| +| Discovery | `/.well-known/openid-configuration` | Returns OIDC metadata. | +| JWKS | `/jwks` | Serves public keys for token verification. | +| Authorize | `/authorize` | Initiates the auth flow. Redirects to Google if no session exists. | +| Token | `/token` | Exchanges authorization codes for ID/Access/Refresh tokens. | +| UserInfo | `/userinfo` | (Optional but recommended) Returns user claims. | +| Callback | `/callback/google` | Receives the redirect from Google Workspace. | + +## Data Mapping + +### User Identification +`knoe-auth` must normalize identities to a consistent `sub` (subject) claim. +- **Kerberos:** `user@REALM` → `user` (via `PrincipalNormalizer`) +- **Google:** `user@knoey.com` → `user` (assuming `knoey.com` is the primary domain) + +GitLab will receive `sub: user` and JIT-create/map the user accordingly. + +### State Management +- **Authorization Codes:** Short-lived, stored in memory or DB (PostgreSQL). +- **Sessions:** `knoe-auth` already uses a `knoe_session` cookie. The OIDC flow will leverage this session. + +## Configuration (Path B) + +### GitLab `omni_auth` (in `conf/gke.cfg`) +```ini +GITLAB_OIDC_ISSUER = https://api.knoe.dev/auth +GITLAB_OIDC_CLIENT_ID = +GITLAB_OIDC_CLIENT_SECRET = +``` + +### knoe-auth `application.yml` +```yaml +knoe: + auth: + oidc: + enabled: true + issuer: https://api.knoe.dev/auth + # RSA key for signing JWTs + signingKey: ${KNOE_AUTH_OIDC_SIGNING_KEY} +``` + +## Implementation Plan + +1. **JWK Management:** Generate or load an RSA key pair for JWT signing. +2. **Discovery Endpoint:** Hardcoded JSON returning the implemented endpoints and supported claims/scopes. +3. **Authorize Logic:** + - Validate `client_id`, `redirect_uri`, `state`. + - Check for `knoe_session`. + - If missing, redirect to `/login` (which redirects to Google). + - If present, generate an auth code, associate it with the session, and redirect back to GitLab. +4. **Token Logic:** + - Validate auth code. + - Generate JWT signed with the private key. + - Include `sub`, `email`, `preferred_username` claims. +5. **Secret Handling:** Update `etc/init_gitlab.sh` and `etc/init_knoe_auth.sh` to handle the new client credentials and signing keys. + +## Verification Steps + +To verify Path B is working correctly: + +1. **Discovery:** + ```bash + curl -s https://api.knoe.dev/auth/.well-known/openid-configuration | jq . + ``` + Should return JSON with `issuer: "https://api.knoe.dev/auth"` and other OIDC endpoints. + +2. **JWKS:** + ```bash + curl -s https://api.knoe.dev/auth/jwks | jq . + ``` + Should return an RSA public key in JWKS format. + +3. **GitLab Redirect:** + Visit `https://git.knoe.dev/users/sign_in`. Click on the "OpenID Connect" button. + - You should be redirected to `https://api.knoe.dev/auth/authorize?...`. + - If not logged in, you should be redirected to `https://api.knoe.dev/auth/login`. + - Click "Login with Google". After Google authentication, you should be redirected back to `knoe-auth` and then to GitLab. + +4. **GitLab Login:** + After the flow, you should be logged into GitLab as your Google Workspace user. Verify in GitLab profile that the email matches. diff --git a/docs/oidc-setup.md b/docs/oidc-setup.md index 8f548f3..937e678 100644 --- a/docs/oidc-setup.md +++ b/docs/oidc-setup.md @@ -16,11 +16,10 @@ Two paths exist: | Path | Description | Status | |---|---|---| -| **A** | GitLab talks OIDC directly to Google Workspace. Issuer = `https://accounts.google.com`. knoe-auth is out of the loop. | **Active — this doc describes it** | -| **B** | GitLab → knoe-auth (OIDC OP) → Google. Issuer = `https://api.knoe.dev/auth`. Aligns with the Kerberos/unified-SSO long-term plan. | Deferred, tracked as task #7 (requires building/adopting an OIDC OP inside knoe-auth) | +| **A** | GitLab talks OIDC directly to Google Workspace. Issuer = `https://accounts.google.com`. knoe-auth is out of the loop. | Deprecated — moved to Path B | +| **B** | GitLab → knoe-auth (OIDC OP) → Google. Issuer = `https://api.knoe.dev/auth`. Aligns with the Kerberos/unified-SSO long-term plan. | **Active — see docs/oidc-path-b.md** | -When Path B is ready, swap `GITLAB_OIDC_ISSUER` back to `https://api.knoe.dev/auth` -and re-run the installer — no other change required downstream. +When Path B is ready (implemented April 2026), `GITLAB_OIDC_ISSUER` is set to `https://api.knoe.dev/auth`. --- diff --git a/etc/init_knoe_auth.sh b/etc/init_knoe_auth.sh index d257ef8..8ae36b2 100755 --- a/etc/init_knoe_auth.sh +++ b/etc/init_knoe_auth.sh @@ -268,6 +268,25 @@ create_session_secret() { info "knoe-auth-secrets created." } +create_oidc_path_b_secret() { + if kube -n "$NAMESPACE" get secret knoe-auth-oidc >/dev/null 2>&1; then + info "knoe-auth-oidc already exists — skipping." + return + fi + + info "Creating knoe-auth-oidc secret for Path B..." + local client_id client_secret signing_key + client_id=$(op_secret "knoe-auth-oidc-gitlab" "client_id") + client_secret=$(op_secret "knoe-auth-oidc-gitlab" "client_secret") + signing_key=$(op_secret "knoe-auth-oidc-signing" "private_key") + + kube -n "$NAMESPACE" create secret generic knoe-auth-oidc \ + --from-literal=client-id="$client_id" \ + --from-literal=client-secret="$client_secret" \ + --from-literal=signing-key="$signing_key" + info "knoe-auth-oidc created." +} + # ── Manifests ──────────────────────────────────────────────────────────────── apply_manifests() { @@ -341,6 +360,7 @@ cmd_initialize() { create_kdc_secrets create_google_oidc_secret create_session_secret + create_oidc_path_b_secret # 3. Apply ConfigMap + Deployment apply_manifests