Files
sonr/bridge/handlers/oidc.go
T

467 lines
13 KiB
Go
Raw Normal View History

2025-10-03 14:45:52 -04:00
package handlers
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
)
// OIDCProvider manages OIDC operations
type OIDCProvider struct {
mu sync.RWMutex
codes map[string]*AuthorizationCode
sessions map[string]*OIDCSession
config any // TODO: Use bridge.OIDCProviderConfig
}
var (
oidcProvider = &OIDCProvider{
codes: make(map[string]*AuthorizationCode),
sessions: make(map[string]*OIDCSession),
}
codeExpiration = 10 * time.Minute
)
// SetOIDCConfig sets the OIDC provider configuration
func SetOIDCConfig(config any) {
oidcProvider.mu.Lock()
defer oidcProvider.mu.Unlock()
oidcProvider.config = config
}
// GetOIDCDiscovery returns OIDC discovery configuration
func GetOIDCDiscovery(c echo.Context) error {
config := &OIDCConfig{
Issuer: "https://localhost:8080",
AuthorizationEndpoint: "https://localhost:8080/oidc/authorize",
TokenEndpoint: "https://localhost:8080/oidc/token",
UserInfoEndpoint: "https://localhost:8080/oidc/userinfo",
JWKSEndpoint: "https://localhost:8080/oidc/jwks",
RevocationEndpoint: "https://localhost:8080/oidc/revoke",
IntrospectionEndpoint: "https://localhost:8080/oidc/introspect",
ScopesSupported: []string{
"openid", "profile", "email", "did", "vault", "offline_access",
},
ResponseTypesSupported: []string{
"code", "id_token", "code id_token",
},
GrantTypesSupported: []string{
"authorization_code", "refresh_token", "client_credentials",
},
SubjectTypesSupported: []string{
"public", "pairwise",
},
IDTokenSigningAlgValuesSupported: []string{
"ES256", "RS256",
},
TokenEndpointAuthMethodsSupported: []string{
"client_secret_post", "client_secret_basic", "none",
},
ClaimsSupported: []string{
"sub", "name", "preferred_username", "email", "email_verified",
"did", "vault_id", "updated_at",
},
CodeChallengeMethodsSupported: []string{
"S256", "plain",
},
}
return c.JSON(http.StatusOK, config)
}
// HandleOIDCAuthorization handles OIDC authorization requests
func HandleOIDCAuthorization(c echo.Context) error {
// Parse request parameters from query string for GET or form for POST
req := OIDCAuthorizationRequest{
ResponseType: c.QueryParam("response_type"),
ClientID: c.QueryParam("client_id"),
RedirectURI: c.QueryParam("redirect_uri"),
Scope: c.QueryParam("scope"),
State: c.QueryParam("state"),
Nonce: c.QueryParam("nonce"),
CodeChallenge: c.QueryParam("code_challenge"),
CodeChallengeMethod: c.QueryParam("code_challenge_method"),
}
// If no query params, try to bind from body (for POST requests)
if req.ResponseType == "" {
_ = c.Bind(&req) // Ignore bind errors and continue
}
// Validate required parameters
if req.ResponseType == "" || req.ClientID == "" || req.RedirectURI == "" || req.Scope == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_request",
"error_description": "Missing required parameters",
})
}
// Validate response type
if req.ResponseType != "code" && req.ResponseType != "id_token" &&
req.ResponseType != "code id_token" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "unsupported_response_type",
"error_description": "Response type not supported",
})
}
// Validate scope includes openid
if !strings.Contains(req.Scope, "openid") {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_scope",
"error_description": "Scope must include 'openid'",
})
}
// TODO: Validate client_id and redirect_uri against registered clients
// For now, assume user is authenticated (in production, redirect to login)
userDID := c.Get("user_did")
if userDID == nil {
// Redirect to WebAuthn authentication
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "authentication_required",
"error_description": "User authentication required",
})
}
// Generate authorization code
code := generateAuthorizationCode()
// Store authorization code
authCode := &AuthorizationCode{
Code: code,
ClientID: req.ClientID,
RedirectURI: req.RedirectURI,
UserDID: userDID.(string),
Scope: req.Scope,
Nonce: req.Nonce,
CodeChallenge: req.CodeChallenge,
CodeChallengeMethod: req.CodeChallengeMethod,
ExpiresAt: time.Now().Add(codeExpiration),
Used: false,
}
oidcProvider.mu.Lock()
oidcProvider.codes[code] = authCode
oidcProvider.mu.Unlock()
// Build redirect URL
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", req.RedirectURI, code, req.State)
return c.Redirect(http.StatusFound, redirectURL)
}
// HandleOIDCToken handles OIDC token requests
func HandleOIDCToken(c echo.Context) error {
var req OIDCTokenRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_request",
"error_description": "Invalid token request",
})
}
// Handle different grant types
switch req.GrantType {
case "authorization_code":
return handleAuthorizationCodeGrant(c, &req)
case "refresh_token":
return handleRefreshTokenGrant(c, &req)
case "client_credentials":
return handleClientCredentialsGrant(c, &req)
default:
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "unsupported_grant_type",
"error_description": "Grant type not supported",
})
}
}
// handleAuthorizationCodeGrant processes authorization code grant
func handleAuthorizationCodeGrant(c echo.Context, req *OIDCTokenRequest) error {
if req.Code == "" || req.RedirectURI == "" || req.ClientID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_request",
"error_description": "Missing required parameters",
})
}
// Retrieve and validate authorization code
oidcProvider.mu.Lock()
authCode, exists := oidcProvider.codes[req.Code]
if exists {
delete(oidcProvider.codes, req.Code) // Single use
}
oidcProvider.mu.Unlock()
if !exists {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Invalid authorization code",
})
}
// Validate code hasn't expired
if time.Now().After(authCode.ExpiresAt) {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Authorization code expired",
})
}
// Validate code hasn't been used
if authCode.Used {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Authorization code already used",
})
}
// Validate client and redirect URI
if authCode.ClientID != req.ClientID || authCode.RedirectURI != req.RedirectURI {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Invalid client or redirect URI",
})
}
// Validate PKCE if present
if authCode.CodeChallenge != "" {
if req.CodeVerifier == "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Code verifier required",
})
}
if !verifyPKCE(req.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid_grant",
"error_description": "Invalid code verifier",
})
}
}
// Mark code as used
authCode.Used = true
// Generate tokens
accessToken := generateAccessToken(authCode.UserDID)
refreshToken := generateRefreshToken()
idToken, err := generateIDToken(authCode.UserDID, authCode.ClientID, authCode.Nonce)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": "server_error",
"error_description": "Failed to generate ID token",
})
}
// Create session
session := &OIDCSession{
SessionID: generateSessionID(),
UserDID: authCode.UserDID,
ClientID: authCode.ClientID,
Scope: authCode.Scope,
Nonce: authCode.Nonce,
AccessToken: accessToken,
RefreshToken: refreshToken,
IDToken: idToken,
ExpiresAt: time.Now().Add(time.Hour),
CreatedAt: time.Now(),
}
oidcProvider.mu.Lock()
oidcProvider.sessions[accessToken] = session
oidcProvider.mu.Unlock()
// Return tokens
response := &OIDCTokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: 3600,
RefreshToken: refreshToken,
IDToken: idToken,
Scope: authCode.Scope,
}
return c.JSON(http.StatusOK, response)
}
// handleRefreshTokenGrant processes refresh token grant
func handleRefreshTokenGrant(c echo.Context, req *OIDCTokenRequest) error {
// TODO: Implement refresh token grant
return c.JSON(http.StatusNotImplemented, map[string]string{
"error": "unsupported_grant_type",
"error_description": "Refresh token grant not yet implemented",
})
}
// handleClientCredentialsGrant processes client credentials grant
func handleClientCredentialsGrant(c echo.Context, req *OIDCTokenRequest) error {
// TODO: Implement client credentials grant
return c.JSON(http.StatusNotImplemented, map[string]string{
"error": "unsupported_grant_type",
"error_description": "Client credentials grant not yet implemented",
})
}
// HandleOIDCUserInfo handles OIDC userinfo requests
func HandleOIDCUserInfo(c echo.Context) error {
// Extract access token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "invalid_token",
"error_description": "Invalid access token",
})
}
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
// Validate access token
oidcProvider.mu.RLock()
session, exists := oidcProvider.sessions[accessToken]
oidcProvider.mu.RUnlock()
if !exists {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "invalid_token",
"error_description": "Invalid or expired access token",
})
}
// Check if token is expired
if time.Now().After(session.ExpiresAt) {
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "invalid_token",
"error_description": "Access token expired",
})
}
// TODO: Fetch actual user info from DID document or database
userInfo := &OIDCUserInfo{
Subject: session.UserDID,
PreferredUsername: "user", // TODO: Get from DID document
DID: session.UserDID,
UpdatedAt: time.Now().Unix(),
}
// Add additional claims based on scope
if strings.Contains(session.Scope, "profile") {
userInfo.Name = "User Name" // TODO: Get from DID document
}
if strings.Contains(session.Scope, "email") {
userInfo.Email = "user@example.com" // TODO: Get from DID document
userInfo.EmailVerified = true
}
if strings.Contains(session.Scope, "vault") {
userInfo.VaultID = "vault_" + session.UserDID // TODO: Get actual vault ID
}
return c.JSON(http.StatusOK, userInfo)
}
// HandleOIDCJWKS handles JWKS endpoint
func HandleOIDCJWKS(c echo.Context) error {
// TODO: Return actual public keys used for signing
jwks := &JWKSet{
Keys: []JWK{
{
KeyType: "EC",
Use: "sig",
KeyID: "1",
Algorithm: "ES256",
Curve: "P-256",
// TODO: Add actual public key coordinates
},
},
}
return c.JSON(http.StatusOK, jwks)
}
// Helper functions
func generateAuthorizationCode() string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func generateAccessToken(userDID string) string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func generateRefreshToken() string {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func generateSessionID() string {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func generateIDToken(userDID, clientID, nonce string) (string, error) {
claims := &DIDAuthClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userDID,
Issuer: "https://localhost:8080",
Audience: []string{clientID},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
DID: userDID,
}
if nonce != "" {
claims.Extra = map[string]any{
"nonce": nonce,
}
}
// TODO: Sign with actual signing key
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte("temporary-secret-key"))
}
func verifyPKCE(verifier, challenge, method string) bool {
var computed string
switch method {
case "S256":
h := sha256.Sum256([]byte(verifier))
computed = base64.RawURLEncoding.EncodeToString(h[:])
case "plain":
computed = verifier
default:
return false
}
return computed == challenge
}