mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
467 lines
13 KiB
Go
467 lines
13 KiB
Go
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
|
||
|
|
}
|