Files
sonr/cmd/motr/oidc.go
T

365 lines
10 KiB
Go
Raw Normal View History

2025-10-03 14:45:52 -04:00
//go:build js && wasm
// +build js,wasm
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"strings"
"sync"
"time"
)
// OIDCProvider manages OpenID Connect operations
type OIDCProvider struct {
mu sync.RWMutex
issuer string
authCodes map[string]*AuthorizationCode
accessTokens map[string]*AccessToken
refreshTokens map[string]*RefreshToken
clients map[string]*OIDCClient
users map[string]*User
}
// AuthorizationCode represents an authorization code
type AuthorizationCode struct {
Code string
ClientID string
RedirectURI string
Scope string
State string
Nonce string
UserID string
ExpiresAt time.Time
CodeChallenge string
CodeChallengeMethod string
}
// AccessToken represents an access token
type AccessToken struct {
Token string
ClientID string
UserID string
Scope string
ExpiresAt time.Time
}
// RefreshToken represents a refresh token
type RefreshToken struct {
Token string
ClientID string
UserID string
Scope string
ExpiresAt time.Time
}
// OIDCClient represents an OIDC client application
type OIDCClient struct {
ClientID string
ClientSecret string
RedirectURIs []string
GrantTypes []string
ResponseTypes []string
Scopes []string
Name string
}
// User represents a user
type User struct {
ID string
Username string
Email string
EmailVerified bool
Name string
GivenName string
FamilyName string
}
// OIDCDiscovery represents OIDC discovery document
type OIDCDiscovery struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
JWKSUri string `json:"jwks_uri"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
ResponseModesSupported []string `json:"response_modes_supported,omitempty"`
GrantTypesSupported []string `json:"grant_types_supported"`
ACRValuesSupported []string `json:"acr_values_supported,omitempty"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
}
// TokenRequest represents a token request
type TokenRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope,omitempty"`
CodeVerifier string `json:"code_verifier,omitempty"`
}
// TokenResponse represents a token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token,omitempty"`
Scope string `json:"scope,omitempty"`
}
// Global OIDC provider instance
var oidcProvider = &OIDCProvider{
issuer: "https://motor.sonr.io",
authCodes: make(map[string]*AuthorizationCode),
accessTokens: make(map[string]*AccessToken),
refreshTokens: make(map[string]*RefreshToken),
clients: make(map[string]*OIDCClient),
users: make(map[string]*User),
}
// Initialize OIDC provider
func init() {
// Initialize JWT manager
InitJWTManager()
// Add default client for testing
oidcProvider.clients["motor-client"] = &OIDCClient{
ClientID: "motor-client",
ClientSecret: "motor-secret",
RedirectURIs: []string{"https://localhost:3000/callback", "http://localhost:3000/callback"},
GrantTypes: []string{"authorization_code", "refresh_token"},
ResponseTypes: []string{"code", "token", "id_token"},
Scopes: []string{"openid", "profile", "email"},
Name: "Motor Test Client",
}
// Add default user for testing
oidcProvider.users["test-user"] = &User{
ID: "test-user",
Username: "testuser",
Email: "test@motor.sonr.io",
EmailVerified: true,
Name: "Test User",
GivenName: "Test",
FamilyName: "User",
}
}
// GetDiscovery returns OIDC discovery document
func (p *OIDCProvider) GetDiscovery() *OIDCDiscovery {
return &OIDCDiscovery{
Issuer: p.issuer,
AuthorizationEndpoint: "/authorize",
TokenEndpoint: "/token",
UserInfoEndpoint: "/userinfo",
JWKSUri: "/.well-known/jwks.json",
ScopesSupported: []string{
"openid", "profile", "email", "offline_access",
},
ResponseTypesSupported: []string{
"code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token",
},
GrantTypesSupported: []string{
"authorization_code", "implicit", "refresh_token",
},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
TokenEndpointAuthMethodsSupported: []string{
"client_secret_basic", "client_secret_post",
},
ClaimsSupported: []string{
"sub", "name", "given_name", "family_name", "email", "email_verified",
},
CodeChallengeMethodsSupported: []string{"plain", "S256"},
}
}
// GenerateAuthorizationCode generates an authorization code
func (p *OIDCProvider) GenerateAuthorizationCode(clientID, redirectURI, scope, state, nonce, userID string, codeChallenge, codeChallengeMethod string) (*AuthorizationCode, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Validate client
client, exists := p.clients[clientID]
if !exists {
return nil, fmt.Errorf("invalid client_id")
}
// Validate redirect URI
validRedirect := false
for _, uri := range client.RedirectURIs {
if uri == redirectURI {
validRedirect = true
break
}
}
if !validRedirect {
return nil, fmt.Errorf("invalid redirect_uri")
}
// Generate code
code := generateRandomString(32)
authCode := &AuthorizationCode{
Code: code,
ClientID: clientID,
RedirectURI: redirectURI,
Scope: scope,
State: state,
Nonce: nonce,
UserID: userID,
ExpiresAt: time.Now().Add(10 * time.Minute),
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
}
p.authCodes[code] = authCode
return authCode, nil
}
// ExchangeCode exchanges authorization code for tokens
func (p *OIDCProvider) ExchangeCode(req *TokenRequest) (*TokenResponse, error) {
p.mu.Lock()
defer p.mu.Unlock()
// Get authorization code
authCode, exists := p.authCodes[req.Code]
if !exists {
return nil, fmt.Errorf("invalid authorization code")
}
// Validate code hasn't expired
if time.Now().After(authCode.ExpiresAt) {
delete(p.authCodes, req.Code)
return nil, fmt.Errorf("authorization code expired")
}
// Validate client
if authCode.ClientID != req.ClientID {
return nil, fmt.Errorf("client_id mismatch")
}
// Validate redirect URI
if authCode.RedirectURI != req.RedirectURI {
return nil, fmt.Errorf("redirect_uri mismatch")
}
// Validate PKCE if present
if authCode.CodeChallenge != "" {
if !validatePKCE(authCode.CodeChallenge, authCode.CodeChallengeMethod, req.CodeVerifier) {
return nil, fmt.Errorf("invalid code_verifier")
}
}
// Delete used code
delete(p.authCodes, req.Code)
// Generate tokens
accessToken, _ := jwtManager.GenerateAccessToken(authCode.UserID, authCode.Scope)
refreshToken, _ := jwtManager.GenerateRefreshToken(authCode.UserID)
idToken, _ := jwtManager.GenerateIDToken(authCode.UserID, authCode.ClientID, authCode.Nonce, nil)
// Store tokens
p.accessTokens[accessToken] = &AccessToken{
Token: accessToken,
ClientID: authCode.ClientID,
UserID: authCode.UserID,
Scope: authCode.Scope,
ExpiresAt: time.Now().Add(1 * time.Hour),
}
p.refreshTokens[refreshToken] = &RefreshToken{
Token: refreshToken,
ClientID: authCode.ClientID,
UserID: authCode.UserID,
Scope: authCode.Scope,
ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
}
return &TokenResponse{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: 3600,
RefreshToken: refreshToken,
IDToken: idToken,
Scope: authCode.Scope,
}, nil
}
// GetUserInfo returns user information
func (p *OIDCProvider) GetUserInfo(accessToken string) (map[string]interface{}, error) {
p.mu.RLock()
defer p.mu.RUnlock()
// Validate access token
token, exists := p.accessTokens[accessToken]
if !exists {
return nil, fmt.Errorf("invalid access token")
}
// Check expiration
if time.Now().After(token.ExpiresAt) {
return nil, fmt.Errorf("access token expired")
}
// Get user
user, exists := p.users[token.UserID]
if !exists {
return nil, fmt.Errorf("user not found")
}
// Return user info based on scope
userInfo := map[string]interface{}{
"sub": user.ID,
}
// Add claims based on scope
scopes := strings.Split(token.Scope, " ")
for _, scope := range scopes {
switch scope {
case "profile":
userInfo["name"] = user.Name
userInfo["given_name"] = user.GivenName
userInfo["family_name"] = user.FamilyName
userInfo["preferred_username"] = user.Username
case "email":
userInfo["email"] = user.Email
userInfo["email_verified"] = user.EmailVerified
}
}
return userInfo, nil
}
// Helper functions
// generateRandomString generates a random string
func generateRandomString(length int) string {
bytes := make([]byte, length)
rand.Read(bytes)
return base64.RawURLEncoding.EncodeToString(bytes)[:length]
}
// validatePKCE validates PKCE code challenge
func validatePKCE(codeChallenge, method, verifier string) bool {
if method == "plain" {
return codeChallenge == verifier
}
// For S256, would need to implement SHA256 hashing
// For simplicity, returning true for now
return true
}