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:
chrisfu 2026-04-28 12:22:45 -07:00
parent d17270bbe2
commit 3728889e25
18 changed files with 702 additions and 11 deletions

View File

@ -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;
}
}

View File

@ -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)");
}

View File

@ -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
) {}
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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,

View File

@ -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 : "");
}
}

View File

@ -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);
}
}

View File

@ -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
));
}
}

View File

@ -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()
));
}
}

View File

@ -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}

View File

@ -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

View File

@ -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"

View 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
View 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.

View File

@ -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`.
---

View File

@ -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