Initial iris repository structure
Some checks failed
CI / build (push) Failing after 36s

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>
This commit is contained in:
2026-01-08 19:23:49 +01:00
commit 00d98879d3
36 changed files with 4181 additions and 0 deletions

195
auth/http_wasm.go Normal file
View File

@@ -0,0 +1,195 @@
//go:build js && wasm
package auth
import (
"encoding/json"
"fmt"
"net/url"
"syscall/js"
)
// WASMHTTPClient handles HTTP requests in WASM environment
type WASMHTTPClient struct{}
// HTTPResult represents the result of an HTTP request
type HTTPResult struct {
Data []byte
Error error
}
// HTTPCallback is called when HTTP request completes
type HTTPCallback func(result HTTPResult)
// FetchJSON performs a GET request and unmarshals JSON response
// This method blocks and should only be called from the main thread
func (c *WASMHTTPClient) FetchJSON(url string, dest interface{}) error {
result := make(chan HTTPResult, 1)
c.FetchJSONAsync(url, func(r HTTPResult) {
result <- r
})
r := <-result
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Data, dest)
}
// FetchJSONAsync performs a GET request asynchronously using callback
func (c *WASMHTTPClient) FetchJSONAsync(url string, callback HTTPCallback) {
// Create fetch promise
promise := js.Global().Call("fetch", url)
// Success handler
var successFunc js.Func
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
response := args[0]
if !response.Get("ok").Bool() {
callback(HTTPResult{
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
})
successFunc.Release()
return nil
}
// Get response text
textPromise := response.Call("text")
var textFunc js.Func
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
text := args[0].String()
callback(HTTPResult{
Data: []byte(text),
})
// Cleanup after callback completes
textFunc.Release()
successFunc.Release()
return nil
})
// Error handler for text promise
var textErrorFunc js.Func
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("failed to read response text: %v", args[0]),
})
// Cleanup
textFunc.Release()
textErrorFunc.Release()
successFunc.Release()
return nil
})
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
return nil
})
// Error handler
var errorFunc js.Func
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("fetch error: %s", args[0].String()),
})
// Cleanup after callback completes
errorFunc.Release()
successFunc.Release()
return nil
})
promise.Call("then", successFunc).Call("catch", errorFunc)
}
// PostForm performs a POST request with form data
func (c *WASMHTTPClient) PostForm(url string, data url.Values, dest interface{}) error {
result := make(chan HTTPResult, 1)
c.PostFormAsync(url, data, func(r HTTPResult) {
result <- r
})
r := <-result
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Data, dest)
}
// PostFormAsync performs a POST request asynchronously using callback
func (c *WASMHTTPClient) PostFormAsync(url string, data url.Values, callback HTTPCallback) {
// Prepare fetch options
headers := map[string]interface{}{
"Content-Type": "application/x-www-form-urlencoded",
}
options := map[string]interface{}{
"method": "POST",
"headers": headers,
"body": data.Encode(),
}
// Convert options to JS object
jsOptions := js.ValueOf(options)
// Make the request
promise := js.Global().Call("fetch", url, jsOptions)
// Success handler
var successFunc js.Func
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
response := args[0]
if !response.Get("ok").Bool() {
callback(HTTPResult{
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
})
successFunc.Release()
return nil
}
// Get response text
textPromise := response.Call("text")
var textFunc js.Func
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
text := args[0].String()
callback(HTTPResult{
Data: []byte(text),
})
// Cleanup after callback completes
textFunc.Release()
successFunc.Release()
return nil
})
// Error handler for text promise
var textErrorFunc js.Func
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("failed to read response text: %v", args[0]),
})
// Cleanup
textFunc.Release()
textErrorFunc.Release()
successFunc.Release()
return nil
})
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
return nil
})
// Error handler
var errorFunc js.Func
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
callback(HTTPResult{
Error: fmt.Errorf("fetch error: %s", args[0].String()),
})
// Cleanup after callback completes
errorFunc.Release()
successFunc.Release()
return nil
})
promise.Call("then", successFunc).Call("catch", errorFunc)
}

306
auth/oidc.go Normal file
View File

@@ -0,0 +1,306 @@
//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)
}

30
auth/types.go Normal file
View File

@@ -0,0 +1,30 @@
package auth
// OIDCConfig holds the OIDC provider configuration
type OIDCConfig struct {
Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
UserInfoURL string `json:"userinfo_endpoint"`
JWKSURL string `json:"jwks_uri"`
ScopesSupported []string `json:"scopes_supported"`
}
// TokenResponse represents the response from token exchange
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// UserInfo represents user information from the OIDC provider
type UserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups,omitempty"`
}