252 lines
8.1 KiB
Go
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)
|
|
}
|