mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
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 <junie@jetbrains.com>
This commit is contained in:
parent
d17270bbe2
commit
3728889e25
@ -16,6 +16,55 @@ public class AuthProperties {
|
||||
private String emailDomain = "knoe.dev";
|
||||
private boolean formEnabled = false;
|
||||
private List<String> 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<String> adminPrincipals) {
|
||||
this.adminPrincipals = adminPrincipals;
|
||||
}
|
||||
|
||||
public Oidc getOidc() {
|
||||
return oidc;
|
||||
}
|
||||
|
||||
public void setOidc(Oidc oidc) {
|
||||
this.oidc = oidc;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
|
||||
@ -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<String, AuthorizationRequest> 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
|
||||
) {}
|
||||
}
|
||||
@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@ -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<SessionUser> 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());
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> getJwks() {
|
||||
if (!authProperties.getOidc().isEnabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(oidcTokenService.getJwks());
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
<p><a href="/auth/spnego?next=%s"><button>Login with Kerberos</button></a></p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2>Google Workspace</h2>
|
||||
<p>Sign in with your @knoey.com account.</p>
|
||||
<p><a href="/auth/login/google?next=%s"><button>Login with Google</button></a></p>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2>Fallback form</h2>
|
||||
<p>Only available when enabled by configuration.</p>
|
||||
@ -103,7 +112,7 @@ public class LoginController {
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".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<Void> 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<Void> 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<Void> formLogin(
|
||||
@RequestParam MultiValueMap<String, String> form,
|
||||
|
||||
@ -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<SessionUser> 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 : "");
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> getConfiguration() {
|
||||
if (!authProperties.getOidc().isEnabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String issuer = authProperties.getOidc().getIssuer();
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> 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<SessionUser> 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()
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
12
conf/gke.cfg
12
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
|
||||
|
||||
@ -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"
|
||||
|
||||
11
deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml
Normal file
11
deploy/gcp/gke/knoe-auth-oidc-secret.example.yaml
Normal file
@ -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}"
|
||||
97
docs/oidc-path-b.md
Normal file
97
docs/oidc-path-b.md
Normal file
@ -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 = <knoe-auth-client-id>
|
||||
GITLAB_OIDC_CLIENT_SECRET = <knoe-auth-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.
|
||||
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user