mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
924 lines
24 KiB
Go
924 lines
24 KiB
Go
package handlers
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/base64"
|
||
|
|
"fmt"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/labstack/echo/v4"
|
||
|
|
"github.com/sonr-io/sonr/crypto/ucan"
|
||
|
|
)
|
||
|
|
|
||
|
|
// OAuth2Provider extends OIDCProvider with full OAuth2 capabilities
|
||
|
|
type OAuth2Provider struct {
|
||
|
|
*OIDCProvider
|
||
|
|
clientRegistry *ClientRegistry
|
||
|
|
scopeMapper *ScopeMapper
|
||
|
|
ucanDelegator *UCANDelegator
|
||
|
|
authCodeStore *AuthCodeStore
|
||
|
|
accessTokenStore *AccessTokenStore
|
||
|
|
refreshTokenStore *RefreshTokenStore
|
||
|
|
consentStore *ConsentStore
|
||
|
|
config *OAuth2Config
|
||
|
|
}
|
||
|
|
|
||
|
|
// AuthCodeStore manages authorization codes
|
||
|
|
type AuthCodeStore struct {
|
||
|
|
mu sync.RWMutex
|
||
|
|
codes map[string]*OAuth2AuthorizationCode
|
||
|
|
}
|
||
|
|
|
||
|
|
// AccessTokenStore manages access tokens
|
||
|
|
type AccessTokenStore struct {
|
||
|
|
mu sync.RWMutex
|
||
|
|
tokens map[string]*OAuth2AccessToken
|
||
|
|
}
|
||
|
|
|
||
|
|
// RefreshTokenStore manages refresh tokens
|
||
|
|
type RefreshTokenStore struct {
|
||
|
|
mu sync.RWMutex
|
||
|
|
tokens map[string]*OAuth2RefreshToken
|
||
|
|
}
|
||
|
|
|
||
|
|
// ConsentStore manages user consent records
|
||
|
|
type ConsentStore struct {
|
||
|
|
mu sync.RWMutex
|
||
|
|
consents map[string]*UserConsent // key: userDID:clientID
|
||
|
|
}
|
||
|
|
|
||
|
|
// UserConsent represents stored user consent
|
||
|
|
type UserConsent struct {
|
||
|
|
UserDID string `json:"user_did"`
|
||
|
|
ClientID string `json:"client_id"`
|
||
|
|
ApprovedScopes []string `json:"approved_scopes"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
ExpiresAt time.Time `json:"expires_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
var oauth2Provider *OAuth2Provider
|
||
|
|
|
||
|
|
// InitializeOAuth2Provider initializes the OAuth2 provider
|
||
|
|
func InitializeOAuth2Provider() {
|
||
|
|
oauth2Provider = &OAuth2Provider{
|
||
|
|
OIDCProvider: oidcProvider,
|
||
|
|
clientRegistry: NewClientRegistry(),
|
||
|
|
scopeMapper: NewScopeMapper(),
|
||
|
|
ucanDelegator: NewUCANDelegator(nil),
|
||
|
|
authCodeStore: &AuthCodeStore{codes: make(map[string]*OAuth2AuthorizationCode)},
|
||
|
|
accessTokenStore: &AccessTokenStore{tokens: make(map[string]*OAuth2AccessToken)},
|
||
|
|
refreshTokenStore: &RefreshTokenStore{tokens: make(map[string]*OAuth2RefreshToken)},
|
||
|
|
consentStore: &ConsentStore{consents: make(map[string]*UserConsent)},
|
||
|
|
config: getDefaultOAuth2Config(),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start cleanup goroutine for expired tokens
|
||
|
|
go oauth2Provider.cleanupExpiredTokens()
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetOAuth2Discovery returns OAuth2 discovery configuration
|
||
|
|
func GetOAuth2Discovery(c echo.Context) error {
|
||
|
|
if oauth2Provider == nil {
|
||
|
|
InitializeOAuth2Provider()
|
||
|
|
}
|
||
|
|
return c.JSON(http.StatusOK, oauth2Provider.config)
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleOAuth2Authorize handles OAuth2 authorization requests
|
||
|
|
func HandleOAuth2Authorize(c echo.Context) error {
|
||
|
|
if oauth2Provider == nil {
|
||
|
|
InitializeOAuth2Provider()
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &OAuth2AuthorizationRequest{
|
||
|
|
ResponseType: c.QueryParam("response_type"),
|
||
|
|
ClientID: c.QueryParam("client_id"),
|
||
|
|
RedirectURI: c.QueryParam("redirect_uri"),
|
||
|
|
Scope: c.QueryParam("scope"),
|
||
|
|
State: c.QueryParam("state"),
|
||
|
|
CodeChallenge: c.QueryParam("code_challenge"),
|
||
|
|
CodeChallengeMethod: c.QueryParam("code_challenge_method"),
|
||
|
|
Nonce: c.QueryParam("nonce"),
|
||
|
|
Prompt: c.QueryParam("prompt"),
|
||
|
|
LoginHint: c.QueryParam("login_hint"),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate client
|
||
|
|
client, err := oauth2Provider.clientRegistry.GetClient(req.ClientID)
|
||
|
|
if err != nil {
|
||
|
|
return oauth2Error(c, "invalid_client", "Unknown client", req.State)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate redirect URI
|
||
|
|
if !client.ValidateRedirectURI(req.RedirectURI) {
|
||
|
|
return oauth2Error(c, "invalid_request", "Invalid redirect URI", req.State)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate response type
|
||
|
|
if !isValidResponseType(req.ResponseType) {
|
||
|
|
return redirectError(
|
||
|
|
c,
|
||
|
|
req.RedirectURI,
|
||
|
|
"unsupported_response_type",
|
||
|
|
"Response type not supported",
|
||
|
|
req.State,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate scopes
|
||
|
|
requestedScopes := parseScopes(req.Scope)
|
||
|
|
if !client.ValidateScopes(requestedScopes) {
|
||
|
|
return redirectError(
|
||
|
|
c,
|
||
|
|
req.RedirectURI,
|
||
|
|
"invalid_scope",
|
||
|
|
"Requested scope not allowed",
|
||
|
|
req.State,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate PKCE for public clients
|
||
|
|
if client.ClientType == "public" && client.RequirePKCE {
|
||
|
|
if req.CodeChallenge == "" {
|
||
|
|
return redirectError(
|
||
|
|
c,
|
||
|
|
req.RedirectURI,
|
||
|
|
"invalid_request",
|
||
|
|
"PKCE required for public clients",
|
||
|
|
req.State,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
if req.CodeChallengeMethod != PKCEMethodS256 {
|
||
|
|
return redirectError(
|
||
|
|
c,
|
||
|
|
req.RedirectURI,
|
||
|
|
"invalid_request",
|
||
|
|
"Only S256 PKCE method supported",
|
||
|
|
req.State,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check authentication
|
||
|
|
userDID := c.Get("user_did")
|
||
|
|
if userDID == nil {
|
||
|
|
// Store authorization request and redirect to authentication
|
||
|
|
sessionID := generateSessionID()
|
||
|
|
// TODO: Store auth request in session store
|
||
|
|
authURL := fmt.Sprintf(
|
||
|
|
"/auth/login?session_id=%s&return_to=%s",
|
||
|
|
sessionID,
|
||
|
|
c.Request().URL.String(),
|
||
|
|
)
|
||
|
|
return c.Redirect(http.StatusFound, authURL)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check consent
|
||
|
|
if client.RequiresConsent &&
|
||
|
|
!oauth2Provider.hasValidConsent(userDID.(string), req.ClientID, requestedScopes) {
|
||
|
|
// Render consent page
|
||
|
|
return renderOAuth2ConsentPage(c, req, client)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate authorization code
|
||
|
|
code := generateSecureToken(32)
|
||
|
|
authCode := &OAuth2AuthorizationCode{
|
||
|
|
Code: code,
|
||
|
|
ClientID: req.ClientID,
|
||
|
|
UserDID: userDID.(string),
|
||
|
|
RedirectURI: req.RedirectURI,
|
||
|
|
Scopes: requestedScopes,
|
||
|
|
State: req.State,
|
||
|
|
Nonce: req.Nonce,
|
||
|
|
CodeChallenge: req.CodeChallenge,
|
||
|
|
CodeChallengeMethod: req.CodeChallengeMethod,
|
||
|
|
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||
|
|
UCANContext: oauth2Provider.buildUCANContext(userDID.(string)),
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store authorization code
|
||
|
|
oauth2Provider.authCodeStore.Store(authCode)
|
||
|
|
|
||
|
|
// Build redirect URL
|
||
|
|
redirectURL := buildAuthorizationRedirect(req.RedirectURI, code, req.State)
|
||
|
|
return c.Redirect(http.StatusFound, redirectURL)
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleOAuth2Token handles OAuth2 token requests
|
||
|
|
func HandleOAuth2Token(c echo.Context) error {
|
||
|
|
if oauth2Provider == nil {
|
||
|
|
InitializeOAuth2Provider()
|
||
|
|
}
|
||
|
|
|
||
|
|
var req OAuth2TokenRequest
|
||
|
|
if err := c.Bind(&req); err != nil {
|
||
|
|
return oauth2TokenError(c, "invalid_request", "Invalid token request")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Authenticate client
|
||
|
|
client, err := oauth2Provider.authenticateClient(c, &req)
|
||
|
|
if err != nil {
|
||
|
|
return oauth2TokenError(c, "invalid_client", "Client authentication failed")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle grant type
|
||
|
|
switch req.GrantType {
|
||
|
|
case "authorization_code":
|
||
|
|
return oauth2Provider.handleAuthorizationCodeGrant(c, client, &req)
|
||
|
|
case "refresh_token":
|
||
|
|
return oauth2Provider.handleRefreshTokenGrant(c, client, &req)
|
||
|
|
case "client_credentials":
|
||
|
|
return oauth2Provider.handleClientCredentialsGrant(c, client, &req)
|
||
|
|
default:
|
||
|
|
return oauth2TokenError(c, "unsupported_grant_type", "Grant type not supported")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleOAuth2Introspection handles token introspection requests
|
||
|
|
func HandleOAuth2Introspection(c echo.Context) error {
|
||
|
|
if oauth2Provider == nil {
|
||
|
|
InitializeOAuth2Provider()
|
||
|
|
}
|
||
|
|
|
||
|
|
var req OAuth2IntrospectionRequest
|
||
|
|
if err := c.Bind(&req); err != nil {
|
||
|
|
return c.JSON(http.StatusBadRequest, &OAuth2IntrospectionResponse{Active: false})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Authenticate client
|
||
|
|
client, err := oauth2Provider.authenticateClient(c, &OAuth2TokenRequest{
|
||
|
|
ClientID: req.ClientID,
|
||
|
|
ClientSecret: req.ClientSecret,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return c.JSON(http.StatusUnauthorized, &OAuth2IntrospectionResponse{Active: false})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Introspect token
|
||
|
|
response := oauth2Provider.introspectToken(req.Token, req.TokenTypeHint, client)
|
||
|
|
return c.JSON(http.StatusOK, response)
|
||
|
|
}
|
||
|
|
|
||
|
|
// HandleOAuth2Revocation handles token revocation requests
|
||
|
|
func HandleOAuth2Revocation(c echo.Context) error {
|
||
|
|
if oauth2Provider == nil {
|
||
|
|
InitializeOAuth2Provider()
|
||
|
|
}
|
||
|
|
|
||
|
|
var req OAuth2RevocationRequest
|
||
|
|
if err := c.Bind(&req); err != nil {
|
||
|
|
return c.NoContent(http.StatusBadRequest)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Authenticate client
|
||
|
|
client, err := oauth2Provider.authenticateClient(c, &OAuth2TokenRequest{
|
||
|
|
ClientID: req.ClientID,
|
||
|
|
ClientSecret: req.ClientSecret,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return c.NoContent(http.StatusUnauthorized)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Revoke token
|
||
|
|
oauth2Provider.revokeToken(req.Token, req.TokenTypeHint, client)
|
||
|
|
return c.NoContent(http.StatusOK)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Private methods
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) handleAuthorizationCodeGrant(
|
||
|
|
c echo.Context,
|
||
|
|
client *OAuth2Client,
|
||
|
|
req *OAuth2TokenRequest,
|
||
|
|
) error {
|
||
|
|
// Retrieve authorization code
|
||
|
|
authCode := p.authCodeStore.Exchange(req.Code)
|
||
|
|
if authCode == nil {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Invalid authorization code")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate code hasn't expired
|
||
|
|
if time.Now().After(authCode.ExpiresAt) {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Authorization code expired")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate client
|
||
|
|
if authCode.ClientID != client.ClientID {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Code was issued to different client")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate redirect URI
|
||
|
|
if authCode.RedirectURI != req.RedirectURI {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Redirect URI mismatch")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate PKCE if present
|
||
|
|
if authCode.CodeChallenge != "" {
|
||
|
|
if !p.validatePKCE(req.CodeVerifier, authCode.CodeChallenge, authCode.CodeChallengeMethod) {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Invalid PKCE verifier")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create UCAN delegation
|
||
|
|
ucanToken, err := p.ucanDelegator.CreateDelegation(
|
||
|
|
authCode.UserDID,
|
||
|
|
client.ClientID,
|
||
|
|
authCode.Scopes,
|
||
|
|
time.Now().Add(time.Hour),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate tokens
|
||
|
|
accessToken := p.generateAccessToken(authCode, ucanToken)
|
||
|
|
refreshToken := p.generateRefreshToken(authCode)
|
||
|
|
|
||
|
|
// Store tokens
|
||
|
|
p.accessTokenStore.Store(accessToken)
|
||
|
|
p.refreshTokenStore.Store(refreshToken)
|
||
|
|
|
||
|
|
// Generate ID token if openid scope present
|
||
|
|
var idToken string
|
||
|
|
if contains(authCode.Scopes, "openid") {
|
||
|
|
idToken, _ = generateIDToken(authCode.UserDID, client.ClientID, authCode.Nonce)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Return token response
|
||
|
|
response := &OAuth2TokenResponse{
|
||
|
|
AccessToken: accessToken.Token,
|
||
|
|
TokenType: "Bearer",
|
||
|
|
ExpiresIn: 3600,
|
||
|
|
RefreshToken: refreshToken.Token,
|
||
|
|
Scope: strings.Join(authCode.Scopes, " "),
|
||
|
|
IDToken: idToken,
|
||
|
|
UCANToken: ucanToken.Raw,
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.JSON(http.StatusOK, response)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) handleRefreshTokenGrant(
|
||
|
|
c echo.Context,
|
||
|
|
client *OAuth2Client,
|
||
|
|
req *OAuth2TokenRequest,
|
||
|
|
) error {
|
||
|
|
// Retrieve refresh token
|
||
|
|
oldRefreshToken := p.refreshTokenStore.Get(req.RefreshToken)
|
||
|
|
if oldRefreshToken == nil {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Invalid refresh token")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate client
|
||
|
|
if oldRefreshToken.ClientID != client.ClientID {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Token was issued to different client")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate expiration
|
||
|
|
if time.Now().After(oldRefreshToken.ExpiresAt) {
|
||
|
|
return oauth2TokenError(c, "invalid_grant", "Refresh token expired")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rotate refresh token
|
||
|
|
p.refreshTokenStore.Revoke(req.RefreshToken)
|
||
|
|
|
||
|
|
// Create new UCAN delegation
|
||
|
|
ucanToken, err := p.ucanDelegator.CreateDelegation(
|
||
|
|
oldRefreshToken.UserDID,
|
||
|
|
client.ClientID,
|
||
|
|
oldRefreshToken.Scopes,
|
||
|
|
time.Now().Add(time.Hour),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate new tokens
|
||
|
|
newAccessToken := &OAuth2AccessToken{
|
||
|
|
Token: generateSecureToken(32),
|
||
|
|
UserDID: oldRefreshToken.UserDID,
|
||
|
|
ClientID: client.ClientID,
|
||
|
|
Scopes: oldRefreshToken.Scopes,
|
||
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
||
|
|
IssuedAt: time.Now(),
|
||
|
|
UCANToken: ucanToken,
|
||
|
|
}
|
||
|
|
|
||
|
|
newRefreshToken := &OAuth2RefreshToken{
|
||
|
|
Token: generateSecureToken(32),
|
||
|
|
AccessToken: newAccessToken.Token,
|
||
|
|
ClientID: client.ClientID,
|
||
|
|
UserDID: oldRefreshToken.UserDID,
|
||
|
|
Scopes: oldRefreshToken.Scopes,
|
||
|
|
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
|
||
|
|
IssuedAt: time.Now(),
|
||
|
|
RotationCount: oldRefreshToken.RotationCount + 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store new tokens
|
||
|
|
p.accessTokenStore.Store(newAccessToken)
|
||
|
|
p.refreshTokenStore.Store(newRefreshToken)
|
||
|
|
|
||
|
|
// Return response
|
||
|
|
response := &OAuth2TokenResponse{
|
||
|
|
AccessToken: newAccessToken.Token,
|
||
|
|
TokenType: "Bearer",
|
||
|
|
ExpiresIn: 3600,
|
||
|
|
RefreshToken: newRefreshToken.Token,
|
||
|
|
Scope: strings.Join(newAccessToken.Scopes, " "),
|
||
|
|
UCANToken: ucanToken.Raw,
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.JSON(http.StatusOK, response)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) handleClientCredentialsGrant(
|
||
|
|
c echo.Context,
|
||
|
|
client *OAuth2Client,
|
||
|
|
req *OAuth2TokenRequest,
|
||
|
|
) error {
|
||
|
|
// Client credentials grant is only for confidential clients
|
||
|
|
if client.ClientType != "confidential" {
|
||
|
|
return oauth2TokenError(
|
||
|
|
c,
|
||
|
|
"unauthorized_client",
|
||
|
|
"Client type not authorized for this grant",
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse requested scopes
|
||
|
|
scopes := parseScopes(req.Scope)
|
||
|
|
if !client.ValidateScopes(scopes) {
|
||
|
|
return oauth2TokenError(c, "invalid_scope", "Requested scope not allowed")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create service-to-service UCAN token
|
||
|
|
ucanToken, err := p.ucanDelegator.CreateServiceDelegation(
|
||
|
|
client.ClientID,
|
||
|
|
scopes,
|
||
|
|
time.Now().Add(time.Hour),
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return oauth2TokenError(c, "server_error", "Failed to create delegation")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate access token
|
||
|
|
accessToken := &OAuth2AccessToken{
|
||
|
|
Token: generateSecureToken(32),
|
||
|
|
UserDID: "", // No user for client credentials
|
||
|
|
ClientID: client.ClientID,
|
||
|
|
Scopes: scopes,
|
||
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
||
|
|
IssuedAt: time.Now(),
|
||
|
|
UCANToken: ucanToken,
|
||
|
|
TokenType: "client_credentials",
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store token
|
||
|
|
p.accessTokenStore.Store(accessToken)
|
||
|
|
|
||
|
|
// Return response
|
||
|
|
response := &OAuth2TokenResponse{
|
||
|
|
AccessToken: accessToken.Token,
|
||
|
|
TokenType: "Bearer",
|
||
|
|
ExpiresIn: 3600,
|
||
|
|
Scope: strings.Join(scopes, " "),
|
||
|
|
UCANToken: ucanToken.Raw,
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.JSON(http.StatusOK, response)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) authenticateClient(
|
||
|
|
c echo.Context,
|
||
|
|
req *OAuth2TokenRequest,
|
||
|
|
) (*OAuth2Client, error) {
|
||
|
|
// Try Basic Auth first
|
||
|
|
if username, password, ok := c.Request().BasicAuth(); ok {
|
||
|
|
client, err := p.clientRegistry.GetClient(username)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if client.ClientType == "confidential" &&
|
||
|
|
subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(password)) == 1 {
|
||
|
|
return client, nil
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("invalid client credentials")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try client_secret_post
|
||
|
|
if req.ClientID != "" && req.ClientSecret != "" {
|
||
|
|
client, err := p.clientRegistry.GetClient(req.ClientID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if client.ClientType == "confidential" &&
|
||
|
|
subtle.ConstantTimeCompare([]byte(client.ClientSecret), []byte(req.ClientSecret)) == 1 {
|
||
|
|
return client, nil
|
||
|
|
}
|
||
|
|
return nil, fmt.Errorf("invalid client credentials")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try client_assertion (JWT)
|
||
|
|
if req.ClientAssertion != "" &&
|
||
|
|
req.ClientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||
|
|
// TODO: Implement JWT client assertion validation
|
||
|
|
return nil, fmt.Errorf("JWT client assertion not yet implemented")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Public client (no authentication)
|
||
|
|
if req.ClientID != "" {
|
||
|
|
client, err := p.clientRegistry.GetClient(req.ClientID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if client.ClientType == "public" {
|
||
|
|
return client, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil, fmt.Errorf("client authentication required")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) validatePKCE(verifier, challenge, method string) bool {
|
||
|
|
if method == "" {
|
||
|
|
method = PKCEMethodPlain
|
||
|
|
}
|
||
|
|
computed := computePKCEChallenge(verifier, method)
|
||
|
|
return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) hasValidConsent(userDID, clientID string, scopes []string) bool {
|
||
|
|
p.consentStore.mu.RLock()
|
||
|
|
defer p.consentStore.mu.RUnlock()
|
||
|
|
|
||
|
|
key := fmt.Sprintf("%s:%s", userDID, clientID)
|
||
|
|
consent, exists := p.consentStore.consents[key]
|
||
|
|
if !exists {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check expiration
|
||
|
|
if time.Now().After(consent.ExpiresAt) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check all requested scopes are approved
|
||
|
|
for _, scope := range scopes {
|
||
|
|
if !contains(consent.ApprovedScopes, scope) {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) buildUCANContext(userDID string) *UCANAuthContext {
|
||
|
|
// TODO: Fetch actual vault and DID document data
|
||
|
|
return &UCANAuthContext{
|
||
|
|
VaultAddress: fmt.Sprintf("vault_%s", userDID),
|
||
|
|
EnclaveDataCID: fmt.Sprintf("cid_%s", userDID),
|
||
|
|
Capabilities: []string{"read", "write", "sign"},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) generateAccessToken(
|
||
|
|
authCode *OAuth2AuthorizationCode,
|
||
|
|
ucanToken *ucan.Token,
|
||
|
|
) *OAuth2AccessToken {
|
||
|
|
return &OAuth2AccessToken{
|
||
|
|
Token: generateSecureToken(32),
|
||
|
|
UserDID: authCode.UserDID,
|
||
|
|
ClientID: authCode.ClientID,
|
||
|
|
Scopes: authCode.Scopes,
|
||
|
|
ExpiresAt: time.Now().Add(time.Hour),
|
||
|
|
IssuedAt: time.Now(),
|
||
|
|
UCANToken: ucanToken,
|
||
|
|
SessionID: generateSessionID(),
|
||
|
|
TokenType: "authorization_code",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) generateRefreshToken(
|
||
|
|
authCode *OAuth2AuthorizationCode,
|
||
|
|
) *OAuth2RefreshToken {
|
||
|
|
return &OAuth2RefreshToken{
|
||
|
|
Token: generateSecureToken(32),
|
||
|
|
ClientID: authCode.ClientID,
|
||
|
|
UserDID: authCode.UserDID,
|
||
|
|
Scopes: authCode.Scopes,
|
||
|
|
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
|
||
|
|
IssuedAt: time.Now(),
|
||
|
|
RotationCount: 0,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) introspectToken(
|
||
|
|
token, tokenTypeHint string,
|
||
|
|
client *OAuth2Client,
|
||
|
|
) *OAuth2IntrospectionResponse {
|
||
|
|
// Try access token first
|
||
|
|
if accessToken := p.accessTokenStore.Get(token); accessToken != nil {
|
||
|
|
if accessToken.ClientID != client.ClientID {
|
||
|
|
return &OAuth2IntrospectionResponse{Active: false}
|
||
|
|
}
|
||
|
|
return &OAuth2IntrospectionResponse{
|
||
|
|
Active: time.Now().Before(accessToken.ExpiresAt),
|
||
|
|
Scope: strings.Join(accessToken.Scopes, " "),
|
||
|
|
ClientID: accessToken.ClientID,
|
||
|
|
Username: accessToken.UserDID,
|
||
|
|
TokenType: "Bearer",
|
||
|
|
ExpiresAt: accessToken.ExpiresAt.Unix(),
|
||
|
|
IssuedAt: accessToken.IssuedAt.Unix(),
|
||
|
|
Subject: accessToken.UserDID,
|
||
|
|
UCANToken: accessToken.UCANToken.Raw,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try refresh token
|
||
|
|
if refreshToken := p.refreshTokenStore.Get(token); refreshToken != nil {
|
||
|
|
if refreshToken.ClientID != client.ClientID {
|
||
|
|
return &OAuth2IntrospectionResponse{Active: false}
|
||
|
|
}
|
||
|
|
return &OAuth2IntrospectionResponse{
|
||
|
|
Active: time.Now().Before(refreshToken.ExpiresAt),
|
||
|
|
Scope: strings.Join(refreshToken.Scopes, " "),
|
||
|
|
ClientID: refreshToken.ClientID,
|
||
|
|
Username: refreshToken.UserDID,
|
||
|
|
TokenType: "refresh_token",
|
||
|
|
ExpiresAt: refreshToken.ExpiresAt.Unix(),
|
||
|
|
IssuedAt: refreshToken.IssuedAt.Unix(),
|
||
|
|
Subject: refreshToken.UserDID,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return &OAuth2IntrospectionResponse{Active: false}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) revokeToken(token, tokenTypeHint string, client *OAuth2Client) {
|
||
|
|
// Try to revoke as access token
|
||
|
|
if p.accessTokenStore.Revoke(token) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try to revoke as refresh token
|
||
|
|
p.refreshTokenStore.Revoke(token)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *OAuth2Provider) cleanupExpiredTokens() {
|
||
|
|
ticker := time.NewTicker(5 * time.Minute)
|
||
|
|
defer ticker.Stop()
|
||
|
|
|
||
|
|
for range ticker.C {
|
||
|
|
// Cleanup expired authorization codes
|
||
|
|
p.authCodeStore.CleanupExpired()
|
||
|
|
|
||
|
|
// Cleanup expired access tokens
|
||
|
|
p.accessTokenStore.CleanupExpired()
|
||
|
|
|
||
|
|
// Cleanup expired refresh tokens
|
||
|
|
p.refreshTokenStore.CleanupExpired()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store methods for token stores
|
||
|
|
|
||
|
|
func (s *AuthCodeStore) Store(code *OAuth2AuthorizationCode) {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.codes[code.Code] = code
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthCodeStore) Exchange(code string) *OAuth2AuthorizationCode {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
authCode, exists := s.codes[code]
|
||
|
|
if !exists || authCode.Used {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
authCode.Used = true
|
||
|
|
return authCode
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AuthCodeStore) CleanupExpired() {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
for code, authCode := range s.codes {
|
||
|
|
if now.After(authCode.ExpiresAt) {
|
||
|
|
delete(s.codes, code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AccessTokenStore) Store(token *OAuth2AccessToken) {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.tokens[token.Token] = token
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AccessTokenStore) Get(token string) *OAuth2AccessToken {
|
||
|
|
s.mu.RLock()
|
||
|
|
defer s.mu.RUnlock()
|
||
|
|
return s.tokens[token]
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AccessTokenStore) Revoke(token string) bool {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
if _, exists := s.tokens[token]; exists {
|
||
|
|
delete(s.tokens, token)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *AccessTokenStore) CleanupExpired() {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
for token, accessToken := range s.tokens {
|
||
|
|
if now.After(accessToken.ExpiresAt) {
|
||
|
|
delete(s.tokens, token)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *RefreshTokenStore) Store(token *OAuth2RefreshToken) {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
s.tokens[token.Token] = token
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *RefreshTokenStore) Get(token string) *OAuth2RefreshToken {
|
||
|
|
s.mu.RLock()
|
||
|
|
defer s.mu.RUnlock()
|
||
|
|
return s.tokens[token]
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *RefreshTokenStore) Revoke(token string) bool {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
if _, exists := s.tokens[token]; exists {
|
||
|
|
delete(s.tokens, token)
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *RefreshTokenStore) CleanupExpired() {
|
||
|
|
s.mu.Lock()
|
||
|
|
defer s.mu.Unlock()
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
for token, refreshToken := range s.tokens {
|
||
|
|
if now.After(refreshToken.ExpiresAt) {
|
||
|
|
delete(s.tokens, token)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Helper functions
|
||
|
|
|
||
|
|
func generateSecureToken(bytes int) string {
|
||
|
|
b := make([]byte, bytes)
|
||
|
|
if _, err := rand.Read(b); err != nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseScopes(scope string) []string {
|
||
|
|
if scope == "" {
|
||
|
|
return []string{}
|
||
|
|
}
|
||
|
|
return strings.Split(scope, " ")
|
||
|
|
}
|
||
|
|
|
||
|
|
func contains(slice []string, item string) bool {
|
||
|
|
for _, s := range slice {
|
||
|
|
if s == item {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func isValidResponseType(responseType string) bool {
|
||
|
|
validTypes := []string{
|
||
|
|
"code",
|
||
|
|
"token",
|
||
|
|
"id_token",
|
||
|
|
"code id_token",
|
||
|
|
"code token",
|
||
|
|
"id_token token",
|
||
|
|
"code id_token token",
|
||
|
|
}
|
||
|
|
return contains(validTypes, responseType)
|
||
|
|
}
|
||
|
|
|
||
|
|
func oauth2Error(c echo.Context, error, description, state string) error {
|
||
|
|
return c.JSON(http.StatusBadRequest, &OAuth2ErrorResponse{
|
||
|
|
Error: error,
|
||
|
|
ErrorDescription: description,
|
||
|
|
State: state,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func oauth2TokenError(c echo.Context, error, description string) error {
|
||
|
|
return c.JSON(http.StatusBadRequest, &OAuth2ErrorResponse{
|
||
|
|
Error: error,
|
||
|
|
ErrorDescription: description,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func redirectError(c echo.Context, redirectURI, error, description, state string) error {
|
||
|
|
url := fmt.Sprintf("%s?error=%s&error_description=%s&state=%s",
|
||
|
|
redirectURI, error, description, state)
|
||
|
|
return c.Redirect(http.StatusFound, url)
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildAuthorizationRedirect(redirectURI, code, state string) string {
|
||
|
|
if strings.Contains(redirectURI, "?") {
|
||
|
|
return fmt.Sprintf("%s&code=%s&state=%s", redirectURI, code, state)
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
|
||
|
|
}
|
||
|
|
|
||
|
|
func renderOAuth2ConsentPage(
|
||
|
|
c echo.Context,
|
||
|
|
req *OAuth2AuthorizationRequest,
|
||
|
|
client *OAuth2Client,
|
||
|
|
) error {
|
||
|
|
// TODO: Render actual consent page
|
||
|
|
return c.JSON(http.StatusOK, map[string]any{
|
||
|
|
"client": client,
|
||
|
|
"scopes": parseScopes(req.Scope),
|
||
|
|
"state": req.State,
|
||
|
|
"client_id": req.ClientID,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func getDefaultOAuth2Config() *OAuth2Config {
|
||
|
|
baseURL := "https://localhost:8080"
|
||
|
|
return &OAuth2Config{
|
||
|
|
Issuer: baseURL,
|
||
|
|
AuthorizationEndpoint: baseURL + "/oauth2/authorize",
|
||
|
|
TokenEndpoint: baseURL + "/oauth2/token",
|
||
|
|
UserInfoEndpoint: baseURL + "/oauth2/userinfo",
|
||
|
|
JWKSEndpoint: baseURL + "/oauth2/jwks",
|
||
|
|
RegistrationEndpoint: baseURL + "/oauth2/register",
|
||
|
|
IntrospectionEndpoint: baseURL + "/oauth2/introspect",
|
||
|
|
RevocationEndpoint: baseURL + "/oauth2/revoke",
|
||
|
|
ScopesSupported: []string{
|
||
|
|
"openid", "profile", "email", "offline_access",
|
||
|
|
"vault:read", "vault:write", "vault:sign", "vault:admin",
|
||
|
|
"service:manage", "did:read", "did:write",
|
||
|
|
},
|
||
|
|
ResponseTypesSupported: []string{
|
||
|
|
"code", "token", "id_token",
|
||
|
|
"code id_token", "code token",
|
||
|
|
"id_token token", "code id_token token",
|
||
|
|
},
|
||
|
|
ResponseModesSupported: []string{
|
||
|
|
"query", "fragment", "form_post",
|
||
|
|
},
|
||
|
|
GrantTypesSupported: []string{
|
||
|
|
"authorization_code", "implicit", "refresh_token",
|
||
|
|
"client_credentials", "urn:ietf:params:oauth:grant-type:device_code",
|
||
|
|
},
|
||
|
|
SubjectTypesSupported: []string{
|
||
|
|
"public", "pairwise",
|
||
|
|
},
|
||
|
|
IDTokenSigningAlgValuesSupported: []string{
|
||
|
|
"ES256", "RS256", "HS256",
|
||
|
|
},
|
||
|
|
TokenEndpointAuthMethodsSupported: []string{
|
||
|
|
"client_secret_basic", "client_secret_post",
|
||
|
|
"client_secret_jwt", "private_key_jwt", "none",
|
||
|
|
},
|
||
|
|
ClaimsSupported: []string{
|
||
|
|
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
|
||
|
|
"name", "given_name", "family_name", "middle_name", "nickname",
|
||
|
|
"preferred_username", "profile", "picture", "website", "email",
|
||
|
|
"email_verified", "did", "vault_id", "ucan_capabilities",
|
||
|
|
},
|
||
|
|
CodeChallengeMethodsSupported: []string{
|
||
|
|
PKCEMethodS256, PKCEMethodPlain,
|
||
|
|
},
|
||
|
|
ServiceDocumentation: baseURL + "/docs/oauth2",
|
||
|
|
UILocalesSupported: []string{"en-US"},
|
||
|
|
UCANSupported: true,
|
||
|
|
}
|
||
|
|
}
|