Files
iris/auth/oidc.go
Hugo Nijhuis 00d98879d3
Some checks failed
CI / build (push) Failing after 36s
Initial iris repository structure
WASM reactive UI framework for Go:
- reactive/ - Signal[T], Effect, Runtime
- ui/ - Button, Text, Input, View, Canvas, SVG components
- navigation/ - Router, guards, history management
- auth/ - OIDC client for WASM applications
- host/ - Static file server

Extracted from arcadia as open-source component.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-08 19:23:49 +01:00

307 lines
8.4 KiB
Go

//go:build js && wasm
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"syscall/js"
"time"
)
// OIDC types are defined in types.go
// OIDCClient handles OIDC authentication flows
type OIDCClient struct {
config *OIDCConfig
clientID string
redirectURI string
scopes []string
// Browser storage
localStorage js.Value
sessionStorage js.Value
// HTTP client for WASM
httpClient *WASMHTTPClient
}
// Token and user info types are defined in types.go
// NewOIDCClient creates a new OIDC client
func NewOIDCClient(issuer, clientID, redirectURI string) *OIDCClient {
client := &OIDCClient{
clientID: clientID,
redirectURI: redirectURI,
scopes: []string{"openid", "email", "profile"},
localStorage: js.Global().Get("localStorage"),
sessionStorage: js.Global().Get("sessionStorage"),
httpClient: &WASMHTTPClient{},
}
return client
}
// DiscoverConfig fetches the OIDC discovery configuration synchronously
func (c *OIDCClient) DiscoverConfig(issuer string) error {
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
// Use fetch to get the configuration
return c.fetchJSON(discoveryURL, &c.config)
}
// DiscoverConfigAsync fetches the OIDC discovery configuration asynchronously
func (c *OIDCClient) DiscoverConfigAsync(issuer string, callback func(error)) {
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
c.httpClient.FetchJSONAsync(discoveryURL, func(result HTTPResult) {
if result.Error != nil {
callback(result.Error)
return
}
// Parse the configuration
var config OIDCConfig
if err := json.Unmarshal(result.Data, &config); err != nil {
callback(fmt.Errorf("failed to parse OIDC config: %w", err))
return
}
c.config = &config
callback(nil)
})
}
// EnsureConfigLoaded ensures OIDC configuration is loaded before proceeding
func (c *OIDCClient) EnsureConfigLoaded(issuer string) error {
if c.config != nil {
return nil
}
return c.DiscoverConfig(issuer)
}
// StartAuthFlow initiates the OIDC authentication flow
func (c *OIDCClient) StartAuthFlow() error {
if c.config == nil {
return fmt.Errorf("OIDC configuration not loaded - call DiscoverConfig first")
}
// Generate PKCE parameters
verifier, err := c.generateCodeVerifier()
if err != nil {
return fmt.Errorf("failed to generate code verifier: %w", err)
}
challenge := c.generateCodeChallenge(verifier)
state := c.generateState()
// Store PKCE verifier and state in session storage
c.sessionStorage.Call("setItem", "pkce_verifier", verifier)
c.sessionStorage.Call("setItem", "auth_state", state)
// Build authorization URL
authURL, err := c.buildAuthURL(challenge, state)
if err != nil {
return fmt.Errorf("failed to build auth URL: %w", err)
}
// Redirect to Dex
js.Global().Get("window").Get("location").Set("href", authURL)
return nil
}
// HandleCallback processes the OAuth callback
func (c *OIDCClient) HandleCallback(issuer string) (*TokenResponse, error) {
// Ensure configuration is loaded before processing
if err := c.EnsureConfigLoaded(issuer); err != nil {
return nil, fmt.Errorf("failed to load OIDC configuration: %w", err)
}
// Get current URL parameters
urlParams := c.getURLParams()
code := urlParams.Get("code")
state := urlParams.Get("state")
errorParam := urlParams.Get("error")
if errorParam != "" {
return nil, fmt.Errorf("OAuth error: %s", errorParam)
}
if code == "" {
return nil, fmt.Errorf("authorization code not found in callback")
}
// Verify state parameter
storedState := c.sessionStorage.Call("getItem", "auth_state").String()
if state != storedState {
return nil, fmt.Errorf("invalid state parameter")
}
// Get PKCE verifier
verifier := c.sessionStorage.Call("getItem", "pkce_verifier").String()
if verifier == "" {
return nil, fmt.Errorf("PKCE verifier not found")
}
// Exchange code for tokens
return c.exchangeCodeForTokens(code, verifier)
}
// GetStoredTokens retrieves tokens from local storage
func (c *OIDCClient) GetStoredTokens() *TokenResponse {
accessToken := c.localStorage.Call("getItem", "access_token")
idToken := c.localStorage.Call("getItem", "id_token")
refreshToken := c.localStorage.Call("getItem", "refresh_token")
if accessToken.IsNull() || idToken.IsNull() {
return nil
}
return &TokenResponse{
AccessToken: accessToken.String(),
IDToken: idToken.String(),
RefreshToken: refreshToken.String(),
TokenType: "Bearer",
}
}
// StoreTokens saves tokens to local storage
func (c *OIDCClient) StoreTokens(tokens *TokenResponse) {
c.localStorage.Call("setItem", "access_token", tokens.AccessToken)
c.localStorage.Call("setItem", "id_token", tokens.IDToken)
if tokens.RefreshToken != "" {
c.localStorage.Call("setItem", "refresh_token", tokens.RefreshToken)
}
// Store expiration time
if tokens.ExpiresIn > 0 {
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
c.localStorage.Call("setItem", "token_expires_at", expiresAt.Unix())
}
}
// ClearTokens removes all stored tokens
func (c *OIDCClient) ClearTokens() {
c.localStorage.Call("removeItem", "access_token")
c.localStorage.Call("removeItem", "id_token")
c.localStorage.Call("removeItem", "refresh_token")
c.localStorage.Call("removeItem", "token_expires_at")
}
// IsAuthenticated checks if the user has valid tokens
func (c *OIDCClient) IsAuthenticated() bool {
tokens := c.GetStoredTokens()
if tokens == nil {
return false
}
// Check if token is expired
expiresAtStr := c.localStorage.Call("getItem", "token_expires_at")
if !expiresAtStr.IsNull() {
// localStorage returns strings, need to convert to int
expiresAtString := expiresAtStr.String()
var expiresAt int64
if _, err := fmt.Sscanf(expiresAtString, "%d", &expiresAt); err == nil {
if time.Now().Unix() > expiresAt {
return false
}
}
}
return tokens.AccessToken != "" && tokens.IDToken != ""
}
// Logout clears all authentication data
func (c *OIDCClient) Logout() {
c.ClearTokens()
c.sessionStorage.Call("removeItem", "pkce_verifier")
c.sessionStorage.Call("removeItem", "auth_state")
}
// GetAuthHeader returns the Authorization header value
func (c *OIDCClient) GetAuthHeader() string {
tokens := c.GetStoredTokens()
if tokens == nil {
return ""
}
return "Bearer " + tokens.IDToken
}
// Helper methods
func (c *OIDCClient) generateCodeVerifier() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
func (c *OIDCClient) generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
func (c *OIDCClient) generateState() string {
bytes := make([]byte, 16)
rand.Read(bytes)
return base64.RawURLEncoding.EncodeToString(bytes)
}
func (c *OIDCClient) buildAuthURL(challenge, state string) (string, error) {
params := url.Values{
"client_id": {c.clientID},
"response_type": {"code"},
"scope": {strings.Join(c.scopes, " ")},
"redirect_uri": {c.redirectURI},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
}
authURL := c.config.AuthURL + "?" + params.Encode()
return authURL, nil
}
func (c *OIDCClient) getURLParams() url.Values {
location := js.Global().Get("window").Get("location")
search := location.Get("search").String()
params, _ := url.ParseQuery(strings.TrimPrefix(search, "?"))
return params
}
func (c *OIDCClient) exchangeCodeForTokens(code, verifier string) (*TokenResponse, error) {
// Prepare form data
data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {c.clientID},
"code": {code},
"redirect_uri": {c.redirectURI},
"code_verifier": {verifier},
}
// Make POST request to token endpoint
var tokens TokenResponse
if err := c.postForm(c.config.TokenURL, data, &tokens); err != nil {
return nil, fmt.Errorf("token exchange failed: %w", err)
}
return &tokens, nil
}
// fetchJSON performs a GET request and unmarshals JSON response
func (c *OIDCClient) fetchJSON(url string, dest interface{}) error {
return c.httpClient.FetchJSON(url, dest)
}
// postForm performs a POST request with form data
func (c *OIDCClient) postForm(url string, data url.Values, dest interface{}) error {
return c.httpClient.PostForm(url, data, dest)
}