package oidc import ( "encoding/base64" "encoding/json" "net/http" "net/url" "strings" "time" "github.com/golang-jwt/jwt/v5" "gitea.perlbach24.de/scripte/nexarch/internal/auth" "gitea.perlbach24.de/scripte/nexarch/internal/moduletrust" ) // IDTokenTTL: kurzlebig wie ein normales Access-Token (Produkt-DNA "so // vertrauenswuerdig wie noetig"), ein Refresh-Flow ist ausdruecklich nicht // Teil dieser Kachel (kleinste Loesung, die die Akzeptanzkriterien erfuellt). const IDTokenTTL = 15 * time.Minute // IDTokenClaims sind die vom Client verifizierbaren Angaben ueber den // angemeldeten Benutzer — signiert mit demselben Schluesselmaterial wie // Modul-zu-Modul-Tokens (internal/moduletrust, API-05), aber mit eigener, // OIDC-spezifischer Struktur (aud, scope), da moduletrust.Claims dafuer // nicht vorgesehen ist. Kein zweites Schluesselsystem — nur eine zweite // Claims-Form desselben Signierschluessels. type IDTokenClaims struct { Subject string `json:"sub"` TenantSlug string `json:"tenant"` Scopes []string `json:"scope"` jwt.RegisteredClaims } // Handler buendelt die drei OIDC-Endpunkte. issuerURL identifiziert NEXARCH // als Aussteller (iss-Claim, Standard-OIDC-Feld). type Handler struct { clients *ClientStore authCodes *AuthCodeStore keys *moduletrust.KeyManager issuerURL string } func NewHandler(clients *ClientStore, authCodes *AuthCodeStore, keys *moduletrust.KeyManager, issuerURL string) *Handler { return &Handler{clients: clients, authCodes: authCodes, keys: keys, issuerURL: issuerURL} } type registerClientRequest struct { Name string `json:"name"` RedirectURIs []string `json:"redirect_uris"` AllowedScopes []string `json:"allowed_scopes"` } type registerClientResponse struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` } // RegisterClient ist Akzeptanzkriterium 1: eine Drittanwendung wird als // OAuth2-Client registriert. Muss hinter auth.RequireAuth haengen — nur // angemeldete Benutzer des jeweiligen Mandanten duerfen Clients fuer ihren // eigenen Mandanten anlegen (ClientStore ist bereits auf die Tenant-DB des // Aufrufers gebunden, siehe NewHandler-Aufstellung im jeweiligen Modul-Server). func (h *Handler) RegisterClient(w http.ResponseWriter, r *http.Request) { if _, ok := auth.ClaimsFromContext(r.Context()); !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } var req registerClientRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungueltiger request-body", http.StatusBadRequest) return } clientID, clientSecret, err := h.clients.Register(r.Context(), req.Name, req.RedirectURIs, req.AllowedScopes) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(registerClientResponse{ClientID: clientID, ClientSecret: clientSecret}) } // Authorize ist Schritt 1 des Authorization-Code-Flows (Akzeptanzkriterium 2). // Muss hinter auth.RequireAuth haengen — der aufrufende Browser ist bereits // als NEXARCH-Benutzer angemeldet, hier wird nur noch der Client geprueft // und ein Code fuer GENAU DIESEN Benutzer ausgestellt. func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) { claims, ok := auth.ClaimsFromContext(r.Context()) if !ok { http.Error(w, "nicht angemeldet", http.StatusUnauthorized) return } q := r.URL.Query() if q.Get("response_type") != "code" { http.Error(w, "nur response_type=code wird unterstuetzt", http.StatusBadRequest) return } clientID := q.Get("client_id") redirectURI := q.Get("redirect_uri") state := q.Get("state") requestedScopes := splitScopes(q.Get("scope")) client, err := h.clients.Get(r.Context(), clientID) if err != nil { http.Error(w, "unbekannter client", http.StatusBadRequest) return } if err := client.ValidateRedirectURI(redirectURI); err != nil { // Bewusst KEIN Redirect zu einer nicht registrierten redirect_uri — // Open-Redirect-Schutz geht vor Komfort, Fehler wird direkt angezeigt. http.Error(w, "redirect_uri ist fuer diesen client nicht registriert", http.StatusBadRequest) return } granted := client.GrantedScopes(requestedScopes) code, err := h.authCodes.Issue(r.Context(), client.ClientID, claims.UserID, redirectURI, granted) if err != nil { http.Error(w, "code konnte nicht ausgestellt werden", http.StatusInternalServerError) return } target, err := url.Parse(redirectURI) if err != nil { http.Error(w, "ungueltige redirect_uri", http.StatusBadRequest) return } values := target.Query() values.Set("code", code) if state != "" { values.Set("state", state) } target.RawQuery = values.Encode() http.Redirect(w, r, target.String(), http.StatusFound) } // Token ist Schritt 2 des Authorization-Code-Flows (Akzeptanzkriterium 2): // die Drittanwendung tauscht den Code serverseitig gegen ein ID-Token. func (h *Handler) Token(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "ungueltiger request-body", http.StatusBadRequest) return } if r.PostForm.Get("grant_type") != "authorization_code" { http.Error(w, "nur grant_type=authorization_code wird unterstuetzt", http.StatusBadRequest) return } clientID := r.PostForm.Get("client_id") clientSecret := r.PostForm.Get("client_secret") code := r.PostForm.Get("code") redirectURI := r.PostForm.Get("redirect_uri") client, err := h.clients.Authenticate(r.Context(), clientID, clientSecret) if err != nil { http.Error(w, "client-authentifizierung fehlgeschlagen", http.StatusUnauthorized) return } data, err := h.authCodes.Consume(r.Context(), code, client.ClientID, redirectURI) if err != nil { http.Error(w, "authorization code ungueltig, abgelaufen oder bereits verwendet", http.StatusBadRequest) return } idToken, err := h.issueIDToken(data) if err != nil { http.Error(w, "token konnte nicht ausgestellt werden", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id_token": idToken, "access_token": idToken, // kein separates Access-Token-Format in dieser Kachel — das ID-Token traegt bereits Subject+Scopes. "token_type": "Bearer", "expires_in": int(IDTokenTTL.Seconds()), "scope": strings.Join(data.Scopes, " "), }) } func (h *Handler) issueIDToken(data AuthCodeData) (string, error) { key, err := h.keys.SigningKey() if err != nil { return "", err } now := time.Now() claims := IDTokenClaims{ Subject: data.UserID, Scopes: data.Scopes, RegisteredClaims: jwt.RegisteredClaims{ Issuer: h.issuerURL, Audience: jwt.ClaimStrings{data.ClientID}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(IDTokenTTL)), }, } token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) token.Header["kid"] = key.KID return token.SignedString(key.Private) } // idpJWK / idpJWKSet folgen RFC 8037 (Ed25519 als OKP-Schluesseltyp) — im // Unterschied zu moduletrust.ServeJWKS (internes, vereinfachtes Format fuer // Modul-zu-Modul-Kommunikation) muss dieser Endpunkt von generischen, // standardkonformen OIDC-Client-Bibliotheken Dritter lesbar sein // (Akzeptanzkriterium 3). Beide Endpunkte liefern dieselben Schluessel aus // demselben KeyManager — kein zweites Schluesselsystem, nur ein zweites, // spezifikationskonformes Format desselben Materials. // // Eigener Typname (statt jwk/jwkSet wie in jwks.go), da jwks.go das RSA- // basierte JWKS-Format fuer die Verifikation EXTERNER OIDC-Provider // implementiert (IAM-12) — dieser Typ hier ist Core als eigener IdP // (IAM-13, Ed25519/OKP) und darf nicht verwechselt werden. type idpJWK struct { Kty string `json:"kty"` Crv string `json:"crv"` Kid string `json:"kid"` X string `json:"x"` Use string `json:"use"` Alg string `json:"alg"` } type idpJWKSet struct { Keys []idpJWK `json:"keys"` } func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) { set := h.keys.PublicKeySet() resp := idpJWKSet{Keys: make([]idpJWK, 0, len(set))} for kid, pub := range set { resp.Keys = append(resp.Keys, idpJWK{ Kty: "OKP", Crv: "Ed25519", Kid: kid, X: base64.RawURLEncoding.EncodeToString(pub), Use: "sig", Alg: "EdDSA", }) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } func splitScopes(raw string) []string { if raw == "" { return nil } return strings.Fields(raw) }