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:
195
auth/http_wasm.go
Normal file
195
auth/http_wasm.go
Normal 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
306
auth/oidc.go
Normal 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
30
auth/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user