diff --git a/examples/auth/main.go b/examples/auth/main.go new file mode 100644 index 0000000..373e41c --- /dev/null +++ b/examples/auth/main.go @@ -0,0 +1,438 @@ +//go:build js && wasm + +// Package main demonstrates OIDC authentication with Iris. +// +// This example shows: +// - OIDC client setup +// - Login/logout flow +// - Protected routes with auth guard +// - Displaying user info +// - Token handling +package main + +import ( + "encoding/base64" + "encoding/json" + "strings" + "syscall/js" + + "git.flowmade.one/flowmade-one/iris/auth" + "git.flowmade.one/flowmade-one/iris/navigation" + "git.flowmade.one/flowmade-one/iris/reactive" + "git.flowmade.one/flowmade-one/iris/ui" +) + +// Configuration - in production, these would come from environment/config +const ( + // OIDC provider configuration + // Update these values for your OIDC provider (e.g., Dex, Keycloak, Auth0) + OIDCIssuer = "https://dex.example.com" + ClientID = "iris-example-app" + RedirectURI = "http://localhost:8080/callback" +) + +var ( + // Global OIDC client + oidcClient *auth.OIDCClient + + // Reactive state for authentication + isAuthenticated = reactive.NewSignal(false) + currentUser = reactive.NewSignal[*UserDisplay](nil) + authError = reactive.NewSignal("") + isLoading = reactive.NewSignal(false) + + // Font presets + fontTitle = ui.NewFont().Size("32px").Weight("700") + fontHeading = ui.NewFont().Size("20px").Weight("600") +) + +// UserDisplay holds user information for display +type UserDisplay struct { + Email string + Name string + Sub string +} + +func main() { + // Initialize OIDC client + oidcClient = auth.NewOIDCClient(OIDCIssuer, ClientID, RedirectURI) + + // Check for existing tokens on startup + checkExistingSession() + + // Check if this is an OAuth callback + if isCallbackPath() { + handleOAuthCallback() + return + } + + // Set up routes + routes := []navigation.Route{ + { + Path: "/", + Handler: homeView, + }, + { + Path: "/callback", + Handler: callbackView, + }, + { + Path: "/profile", + Handler: profileView, + Guards: []navigation.RouteGuard{authGuard()}, + }, + { + Path: "/protected", + Handler: protectedView, + Guards: []navigation.RouteGuard{authGuard()}, + }, + } + + router := navigation.NewRouter(routes) + router.SetNotFoundHandler(notFoundView) + navigation.SetGlobalRouter(router) + + // Create the main app with router + ui.NewAppWithRouter(router) + + // Keep the application running + select {} +} + +// checkExistingSession checks for existing valid tokens +func checkExistingSession() { + if oidcClient.IsAuthenticated() { + isAuthenticated.Set(true) + loadUserFromToken() + } +} + +// isCallbackPath checks if current path is the OAuth callback +func isCallbackPath() bool { + pathname := js.Global().Get("window").Get("location").Get("pathname").String() + search := js.Global().Get("window").Get("location").Get("search").String() + return pathname == "/callback" && strings.Contains(search, "code=") +} + +// handleOAuthCallback processes the OAuth callback +func handleOAuthCallback() { + isLoading.Set(true) + + // Discover OIDC configuration and handle callback + oidcClient.DiscoverConfigAsync(OIDCIssuer, func(err error) { + if err != nil { + authError.Set("Failed to load OIDC configuration: " + err.Error()) + isLoading.Set(false) + renderApp() + return + } + + // Exchange code for tokens + tokens, err := oidcClient.HandleCallback(OIDCIssuer) + if err != nil { + authError.Set("Authentication failed: " + err.Error()) + isLoading.Set(false) + renderApp() + return + } + + // Store tokens + oidcClient.StoreTokens(tokens) + isAuthenticated.Set(true) + loadUserFromToken() + isLoading.Set(false) + + // Clear URL parameters and redirect to home + js.Global().Get("window").Get("history").Call("replaceState", nil, "", "/") + renderApp() + }) +} + +// renderApp initializes and renders the app after callback processing +func renderApp() { + routes := []navigation.Route{ + { + Path: "/", + Handler: homeView, + }, + { + Path: "/callback", + Handler: callbackView, + }, + { + Path: "/profile", + Handler: profileView, + Guards: []navigation.RouteGuard{authGuard()}, + }, + { + Path: "/protected", + Handler: protectedView, + Guards: []navigation.RouteGuard{authGuard()}, + }, + } + + router := navigation.NewRouter(routes) + router.SetNotFoundHandler(notFoundView) + navigation.SetGlobalRouter(router) + + ui.NewAppWithRouter(router) + + select {} +} + +// loadUserFromToken extracts user info from the ID token +func loadUserFromToken() { + tokens := oidcClient.GetStoredTokens() + if tokens == nil || tokens.IDToken == "" { + return + } + + // Parse the ID token to get user info (JWT payload is the second part) + parts := strings.Split(tokens.IDToken, ".") + if len(parts) != 3 { + return + } + + // Decode the payload + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return + } + + currentUser.Set(&UserDisplay{ + Email: claims.Email, + Name: claims.Name, + Sub: claims.Sub, + }) +} + +// authGuard creates a route guard that checks authentication +func authGuard() navigation.RouteGuard { + return navigation.AuthGuard(func() bool { + return isAuthenticated.Get() + }) +} + +// login initiates the OIDC login flow +func login() { + isLoading.Set(true) + authError.Set("") + + oidcClient.DiscoverConfigAsync(OIDCIssuer, func(err error) { + if err != nil { + authError.Set("Failed to connect to authentication provider: " + err.Error()) + isLoading.Set(false) + return + } + + if err := oidcClient.StartAuthFlow(); err != nil { + authError.Set("Failed to start login: " + err.Error()) + isLoading.Set(false) + } + // Note: If successful, the browser will redirect to the OIDC provider + }) +} + +// logout clears authentication state +func logout() { + oidcClient.Logout() + isAuthenticated.Set(false) + currentUser.Set(nil) + navigation.Navigate("/") +} + +// View functions + +func homeView(params map[string]string) ui.View { + return ui.VerticalGroup( + header(), + ui.VerticalGroup( + ui.TextFromString("Iris Auth Example"). + Font(fontTitle). + Margin("20px 0"), + ui.TextFromString("This example demonstrates OIDC authentication with protected routes."). + Color("#666"), + ui.TextFromFunction(func() string { + if authError.Get() != "" { + return "Error: " + authError.Get() + } + return "" + }).Color("#ff4444"), + authSection(), + ).Padding("20px").MaxWidth("800px"), + ).Gap("0") +} + +func header() ui.View { + return ui.HorizontalGroup( + ui.TextFromString("Auth Example").Font(fontHeading), + navigation.Link("/", ui.TextFromString("Home")), + navigation.Link("/profile", ui.TextFromString("Profile")), + navigation.Link("/protected", ui.TextFromString("Protected")), + ui.Spacer(), + authButton(), + ).Padding("16px 24px").Background("#f5f5f5").BorderBottom("1px solid #ddd") +} + +func authSection() ui.View { + return ui.VerticalGroup( + ui.TextFromFunction(func() string { + if isLoading.Get() { + return "Loading..." + } + if isAuthenticated.Get() { + user := currentUser.Get() + if user != nil { + return "Welcome, " + user.Name + "!" + } + return "You are logged in." + } + return "You are not logged in." + }).Margin("20px 0"), + + ui.TextFromFunction(func() string { + if isAuthenticated.Get() { + return "Navigate to Profile or Protected pages to see authenticated content." + } + return "Click Login to authenticate with your OIDC provider." + }).Color("#666"), + ).Padding("20px").Background("#fafafa").BorderRadius("8px").Margin("20px 0") +} + +func authButton() ui.View { + return ui.Button(func() { + if isAuthenticated.Get() { + logout() + } else { + login() + } + }, ui.TextFromFunction(func() string { + if isLoading.Get() { + return "Loading..." + } + if isAuthenticated.Get() { + return "Logout" + } + return "Login" + })).Padding("8px 16px").Background("#007bff").Foreground("#fff"). + Border("none").BorderRadius("4px").Cursor("pointer") +} + +func callbackView(params map[string]string) ui.View { + return ui.VerticalGroup( + header(), + ui.VerticalGroup( + ui.TextFromString("Processing login...").Font(fontHeading), + ui.TextFromString("Please wait while we complete authentication.").Color("#666"), + ).Padding("40px"), + ).Gap("0") +} + +func profileView(params map[string]string) ui.View { + return ui.VerticalGroup( + header(), + ui.VerticalGroup( + ui.TextFromString("User Profile").Font(fontTitle).Margin("20px 0"), + userInfoCard(), + tokenInfoCard(), + ).Padding("20px").MaxWidth("800px"), + ).Gap("0") +} + +func userInfoCard() ui.View { + return ui.VerticalGroup( + ui.TextFromString("User Information").Font(fontHeading), + ui.TextFromFunction(func() string { + user := currentUser.Get() + if user == nil { + return "No user information available" + } + return "Name: " + user.Name + }), + ui.TextFromFunction(func() string { + user := currentUser.Get() + if user == nil { + return "" + } + return "Email: " + user.Email + }), + ui.TextFromFunction(func() string { + user := currentUser.Get() + if user == nil { + return "" + } + return "Subject: " + user.Sub + }).Color("#888"), + ).Padding("20px").Background("#fafafa").BorderRadius("8px").Margin("10px 0").Gap("8px") +} + +func tokenInfoCard() ui.View { + return ui.VerticalGroup( + ui.TextFromString("Token Information").Font(fontHeading), + ui.TextFromFunction(func() string { + tokens := oidcClient.GetStoredTokens() + if tokens == nil { + return "No tokens available" + } + return "Access Token: " + truncateToken(tokens.AccessToken) + }), + ui.TextFromFunction(func() string { + tokens := oidcClient.GetStoredTokens() + if tokens == nil { + return "" + } + return "ID Token: " + truncateToken(tokens.IDToken) + }), + ui.TextFromFunction(func() string { + authHeader := oidcClient.GetAuthHeader() + if authHeader == "" { + return "" + } + return "Auth Header: " + truncateToken(authHeader) + }).Color("#888"), + ).Padding("20px").Background("#fafafa").BorderRadius("8px").Margin("10px 0").Gap("8px") +} + +func protectedView(params map[string]string) ui.View { + return ui.VerticalGroup( + header(), + ui.VerticalGroup( + ui.TextFromString("Protected Content").Font(fontTitle).Margin("20px 0"), + ui.TextFromString("This page is only visible to authenticated users.").Color("#666"), + ui.VerticalGroup( + ui.TextFromString("Access Granted").Font(fontHeading).Color("#28a745"), + ui.TextFromString("You have successfully accessed a protected route."), + ui.TextFromString("The auth guard verified your authentication status before allowing access.").Color("#888"), + ).Padding("20px").Background("#e8f5e9").BorderRadius("8px").Margin("20px 0"), + ).Padding("20px").MaxWidth("800px"), + ).Gap("0") +} + +func notFoundView() ui.View { + return ui.VerticalGroup( + header(), + ui.VerticalGroup( + ui.TextFromString("404 - Page Not Found").Font(fontTitle).Color("#ff4444"), + ui.TextFromString("The requested page could not be found.").Color("#666"), + navigation.Link("/", ui.TextFromString("Go to Home").Color("#007bff")), + ).Padding("40px"), + ).Gap("0") +} + +// Helper functions + +func truncateToken(token string) string { + if len(token) <= 20 { + return token + } + return token[:10] + "..." + token[len(token)-10:] +}