Files
nexarch/internal/oidc/provider.go
T

252 lines
8.1 KiB
Go

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)
}
// jwk / jwkSet 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.
type jwk 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 jwkSet struct {
Keys []jwk `json:"keys"`
}
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
set := h.keys.PublicKeySet()
resp := jwkSet{Keys: make([]jwk, 0, len(set))}
for kid, pub := range set {
resp.Keys = append(resp.Keys, jwk{
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)
}