From f05fcbf7708c8f25fef0ffe2190abbcae2374168 Mon Sep 17 00:00:00 2001 From: Brendan Gerrity Date: Thu, 15 Feb 2024 11:36:38 -0500 Subject: [PATCH] support oidc implicit flow --- package.json | 1 + pnpm-lock.yaml | 10 +- server/config/development.yaml | 13 +- server/docker/README.md | 1 + server/docker/config-template.yaml | 2 + server/server/api/handler.go | 27 ++- server/server/config/auth.go | 24 ++- server/server/config/config.go | 18 +- server/server/route/auth.go | 47 +++-- src/lib/components/top-nav.svelte | 2 +- src/lib/services/settings-service.ts | 7 + src/lib/stores/auth-user.ts | 17 +- src/lib/types/global.ts | 12 ++ src/lib/types/index.ts | 14 +- src/lib/utilities/is-authorized.test.ts | 52 ++++-- src/lib/utilities/is-authorized.ts | 9 +- src/lib/utilities/request-from-api.test.ts | 2 +- src/lib/utilities/request-from-api.ts | 14 +- src/lib/utilities/route-for.test.ts | 191 ++++++++++++++++++++- src/lib/utilities/route-for.ts | 133 +++++++++++++- src/routes/(app)/+layout.ts | 68 +++++++- 21 files changed, 593 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 3f684a16d..0bcffb03f 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "json-bigint": "^1.0.0", "just-debounce": "^1.1.0", "just-throttle": "^4.2.0", + "jwt-decode": "^4.0.0", "lodash.groupby": "^4.6.0", "remark-stringify": "^10.0.3", "sanitize-html": "^2.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb2884c26..20488033a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -80,6 +80,9 @@ dependencies: just-throttle: specifier: ^4.2.0 version: 4.2.0 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 lodash.groupby: specifier: ^4.6.0 version: 4.6.0 @@ -10710,6 +10713,11 @@ packages: resolution: {integrity: sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg==} dev: false + /jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + dev: false + /keycharm@0.4.0: resolution: {integrity: sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==} dev: false diff --git a/server/config/development.yaml b/server/config/development.yaml index a88dc9f47..846f8d539 100644 --- a/server/config/development.yaml +++ b/server/config/development.yaml @@ -20,7 +20,7 @@ batchActionsDisabled: false hideWorkflowQueryErrors: false auth: enabled: false - providers: + providers: # only the first is used. second is provided for reference - label: Auth0 oidc # for internal use; in future may expose as button text type: oidc # for futureproofing; only oidc is supported today providerUrl: https://myorg.us.auth0.com/ @@ -36,6 +36,17 @@ auth: audience: myorg-dev organization: org_xxxxxxxxxxxx invitation: + - label: oidc implicit flow + type: oidc + flow: implicit + # TODO: support optional issuer validation + authorizationUrl: https://accounts.google.com/o/oauth2/v2/auth # discovery isn't supported for implicit flow. the endpoint must be provided directly + clientId: xxxxxxxxxxxxxxxxxxxx + scopes: + - openid + - profile + - email + callbackUrl: http://localhost:8080 tls: caFile: certFile: diff --git a/server/docker/README.md b/server/docker/README.md index 45d921e8c..473e86189 100644 --- a/server/docker/README.md +++ b/server/docker/README.md @@ -15,6 +15,7 @@ docker run \ -e TEMPORAL_ADDRESS=127.0.0.1:7233 \ -e TEMPORAL_UI_PORT=8080 \ -e TEMPORAL_AUTH_ENABLED=true \ + -e TEMPORAL_AUTH_FLOW_TYPE=authorization-code \ -e TEMPORAL_AUTH_PROVIDER_URL=https://accounts.google.com \ -e TEMPORAL_AUTH_CLIENT_ID=xxxxx-xxxx.apps.googleusercontent.com \ -e TEMPORAL_AUTH_CLIENT_SECRET=xxxxxxxxxxxxxxx \ diff --git a/server/docker/config-template.yaml b/server/docker/config-template.yaml index 583d2ee08..696940275 100644 --- a/server/docker/config-template.yaml +++ b/server/docker/config-template.yaml @@ -40,8 +40,10 @@ auth: providers: - label: {{ default .Env.TEMPORAL_AUTH_LABEL "sso" }} type: {{ default .Env.TEMPORAL_AUTH_TYPE "oidc" }} + flow: {{ default .Env.TEMPORAL_AUTH_FLOW_TYPE "authorization-code" }} providerUrl: {{ .Env.TEMPORAL_AUTH_PROVIDER_URL }} issuerUrl: {{ default .Env.TEMPORAL_AUTH_ISSUER_URL "" }} + authorizationUrl:: {{ default .Env.TEMPORAL_AUTH_AUTHORIZATION_URL "" }} clientId: {{ .Env.TEMPORAL_AUTH_CLIENT_ID }} clientSecret: {{ .Env.TEMPORAL_AUTH_CLIENT_SECRET }} callbackUrl: {{ .Env.TEMPORAL_AUTH_CALLBACK_URL }} diff --git a/server/server/api/handler.go b/server/server/api/handler.go index a9f3233d1..cf7abc359 100644 --- a/server/server/api/handler.go +++ b/server/server/api/handler.go @@ -45,8 +45,15 @@ import ( ) type Auth struct { - Enabled bool - Options []string + Enabled bool + Flow string + ProviderURL string + IssuerURL string + AuthorizationURL string + ClientID string + CallbackURL string + Scopes []string + Options []string } type CodecResponse struct { @@ -110,17 +117,25 @@ func GetSettings(cfgProvider *config.ConfigProviderWithRefresh) func(echo.Contex } var options []string + var authProviderCfg config.AuthProvider if len(cfg.Auth.Providers) != 0 { - authProviderCfg := cfg.Auth.Providers[0].Options - for k := range authProviderCfg { + authProviderCfg = cfg.Auth.Providers[0] + for k := range authProviderCfg.Options { options = append(options, k) } } settings := &SettingsResponse{ Auth: &Auth{ - Enabled: cfg.Auth.Enabled, - Options: options, + Enabled: cfg.Auth.Enabled, + Flow: authProviderCfg.Flow, + ProviderURL: authProviderCfg.ProviderURL, + IssuerURL: authProviderCfg.IssuerURL, + AuthorizationURL: authProviderCfg.AuthorizationURL, + ClientID: authProviderCfg.ClientID, + CallbackURL: authProviderCfg.CallbackURL, + Scopes: authProviderCfg.Scopes, + Options: options, }, BannerText: cfg.BannerText, DefaultNamespace: cfg.DefaultNamespace, diff --git a/server/server/config/auth.go b/server/server/config/auth.go index 484a09f6c..17ce0020d 100644 --- a/server/server/config/auth.go +++ b/server/server/config/auth.go @@ -52,8 +52,28 @@ func (c *AuthProvider) validate() error { return nil } - if c.ProviderURL == "" { - return errors.New("auth provider url is not") + switch flowType := c.Flow; flowType { + case "authorization-code": + if c.ProviderURL == "" { + return errors.New("auth provider url is not set") + } + if c.AuthorizationURL != "" { + return errors.New("auth endpoint url is not used in auth code flow") + } + case "implicit": + // TODO: support oidc discovery in implicit flow + if c.ProviderURL != "" { + return errors.New("auth provider url is not used in implicit flow") + } + // TODO: support optional issuer validation + if c.AuthorizationURL == "" { + return errors.New("auth issuer url is not set") + } + if c.ClientSecret != "" { + return errors.New("no secrets in implicit flow") + } + default: + return errors.New("auth oidc flow is not valid") } if c.ClientID == "" { diff --git a/server/server/config/config.go b/server/server/config/config.go index 8138498cf..4ad5436ca 100644 --- a/server/server/config/config.go +++ b/server/server/config/config.go @@ -93,19 +93,23 @@ type ( } AuthProvider struct { - // Label - optional label for the provider + // Optional. Label for the provider Label string `yaml:"label"` // Type of the auth provider. Only OIDC is supported today Type string `yaml:"type"` - // OIDC .well-known/openid-configuration URL, ex. https://accounts.google.com/ + // OIDC login flow type. The "authorization-code" and "implicit" flows are supported + Flow string `yaml:"flow"` + // OIDC .well-known/openid-configuration URL, e.g. https://accounts.google.com/. Discovery unsupported in implicit flow. ProviderURL string `yaml:"providerUrl"` - // IssuerUrl - optional. Needed only when differs from the auth provider URL - IssuerUrl string `yaml:"issuerUrl"` - ClientID string `yaml:"clientId"` - ClientSecret string `yaml:"clientSecret"` + // Optional. Needed only when differs from the auth provider URL. In implicit flow, enables token issuer validation. + IssuerURL string `yaml:"issuerUrl"` + // Required for implicit flow. OIDC authorization endpoint URL, e.g. https://accounts.google.com/o/oauth2/v2/auth + AuthorizationURL string `yaml:"authorizationUrl"` + ClientID string `yaml:"clientId"` + ClientSecret string `yaml:"clientSecret"` // Scopes for auth. Typically [openid, profile, email] Scopes []string `yaml:"scopes"` - // CallbackURL - URL for the callback URL, ex. https://localhost:8080/sso/callback + // URL for the callback, e.g. https://localhost:8080/sso/callback CallbackURL string `yaml:"callbackUrl"` // Options added as URL query params when redirecting to auth provider. Can be used to configure custom auth flows such as Auth0 invitation flow. Options map[string]interface{} `yaml:"options"` diff --git a/server/server/route/auth.go b/server/server/route/auth.go index 21cab9972..cbc4b79e9 100644 --- a/server/server/route/auth.go +++ b/server/server/route/auth.go @@ -59,26 +59,37 @@ func SetAuthRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh) providerCfg := serverCfg.Auth.Providers[0] // only single provider is currently supported - if len(providerCfg.IssuerUrl) > 0 { - ctx = oidc.InsecureIssuerURLContext(ctx, providerCfg.IssuerUrl) - } - provider, err := oidc.NewProvider(ctx, providerCfg.ProviderURL) - if err != nil { - log.Fatal(err) - } + api := e.Group("/auth") + switch providerCfg.Flow { + case "authorization-code": + if len(providerCfg.IssuerURL) > 0 { + ctx = oidc.InsecureIssuerURLContext(ctx, providerCfg.IssuerURL) + } - oauthCfg := oauth2.Config{ - ClientID: providerCfg.ClientID, - ClientSecret: providerCfg.ClientSecret, - Endpoint: provider.Endpoint(), - RedirectURL: providerCfg.CallbackURL, - Scopes: providerCfg.Scopes, - } + if len(providerCfg.AuthorizationURL) > 0 { + log.Fatal(`authorization url should not be set for auth code flow`) + } - api := e.Group("/auth") - api.GET("/sso", authenticate(&oauthCfg, providerCfg.Options)) - api.GET("/sso/callback", authenticateCb(ctx, &oauthCfg, provider)) - api.GET("/sso_callback", authenticateCb(ctx, &oauthCfg, provider)) // compatibility with UI v1 + provider, err := oidc.NewProvider(ctx, providerCfg.ProviderURL) + if err != nil { + log.Fatal(err) + } + + oauthCfg := oauth2.Config{ + ClientID: providerCfg.ClientID, + ClientSecret: providerCfg.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: providerCfg.CallbackURL, + Scopes: providerCfg.Scopes, + } + + api.GET("/sso", authenticate(&oauthCfg, providerCfg.Options)) + api.GET("/sso/callback", authenticateCb(ctx, &oauthCfg, provider)) + api.GET("/sso_callback", authenticateCb(ctx, &oauthCfg, provider)) // compatibility with UI v1 + case "implicit": + // The implicit flow is principally designed for single-page applications. + // Fully delegated to the client. + } } func authenticate(config *oauth2.Config, options map[string]interface{}) func(echo.Context) error { diff --git a/src/lib/components/top-nav.svelte b/src/lib/components/top-nav.svelte index 4b6b5275d..dd3f10e63 100644 --- a/src/lib/components/top-nav.svelte +++ b/src/lib/components/top-nav.svelte @@ -77,7 +77,7 @@ {#if showNamespaceSpecificNav} {/if} - {#if $authUser.accessToken} + {#if $authUser.idToken || $authUser.accessToken} => { const settingsInformation = { auth: { enabled: !!settingsResponse?.Auth?.Enabled, + flow: settingsResponse?.Auth?.Flow, + providerUrl: settingsResponse?.Auth?.ProviderURL, + issuerUrl: settingsResponse?.Auth?.IssuerURL, + authorizationUrl: settingsResponse?.Auth?.AuthorizationURL, + clientId: settingsResponse?.Auth?.ClientID, + callbackUrl: settingsResponse?.Auth?.CallbackURL, + scopes: settingsResponse?.Auth?.Scopes, options: settingsResponse?.Auth?.Options, }, bannerText: settingsResponse?.BannerText, diff --git a/src/lib/stores/auth-user.ts b/src/lib/stores/auth-user.ts index 4987f9828..be029ae6a 100644 --- a/src/lib/stores/auth-user.ts +++ b/src/lib/stores/auth-user.ts @@ -2,16 +2,27 @@ import { get } from 'svelte/store'; import { persistStore } from '$lib/stores/persist-store'; import type { User } from '$lib/types/global'; +import { OIDCFlow } from '$lib/types/global'; export const authUser = persistStore('AuthUser', {}); export const getAuthUser = (): User => get(authUser); -export const setAuthUser = (user: User) => { +export const setAuthUser = (user: User, oidcFlow: OIDCFlow) => { const { accessToken, idToken, name, email, picture } = user; - if (!accessToken) { - throw new Error('No access token'); + switch (oidcFlow) { + case OIDCFlow.AuthorizationCode: + default: + if (!accessToken) { + throw new Error('No access token'); + } + break; + case OIDCFlow.Implicit: + if (!idToken) { + throw new Error('No id token'); + } + break; } authUser.set({ diff --git a/src/lib/types/global.ts b/src/lib/types/global.ts index f8d7e88fa..8946b71c4 100644 --- a/src/lib/types/global.ts +++ b/src/lib/types/global.ts @@ -69,9 +69,21 @@ export interface NetworkError { message?: string; } +export enum OIDCFlow { + AuthorizationCode = 'authorization-code', + Implicit = 'implicit', +} + export type Settings = { auth: { enabled: boolean; + flow: OIDCFlow; + providerUrl: string; + issuerUrl: string; + authorizationUrl: string; + clientId: string; + callbackUrl: string; + scopes: string[]; options: string[]; }; bannerText: string; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 9d5273d4f..db87ac25b 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,5 +1,7 @@ import type { google, temporal } from '@temporalio/proto'; +import type { OIDCFlow } from '$lib/types/global'; + // api.workflowservice export type DescribeNamespaceResponse = @@ -200,7 +202,17 @@ export type Timestamp = google.protobuf.ITimestamp; // extra APIs export type SettingsResponse = { - Auth: { Enabled: boolean; Options: string[] }; + Auth: { + Enabled: boolean; + Flow: OIDCFlow; + ProviderURL: string; + IssuerURL: string; + AuthorizationURL: string; + ClientID: string; + CallbackURL: string; + Scopes: string[]; + Options: string[]; + }; BannerText: string; Codec: { Endpoint: string; diff --git a/src/lib/utilities/is-authorized.test.ts b/src/lib/utilities/is-authorized.test.ts index cb25b5507..9046457e1 100644 --- a/src/lib/utilities/is-authorized.test.ts +++ b/src/lib/utilities/is-authorized.test.ts @@ -2,20 +2,24 @@ import { describe, expect, it } from 'vitest'; import { isAuthorized } from './is-authorized'; -const user = { - accessToken: 'xxx', +const codeUser = { + accessToken: 'accessToken', +}; + +const implicitUser = { + idToken: 'idToken', }; const noUser = { name: 'name', email: 'email', picture: 'picture', - idToken: 'idToken', }; -const getSettings = (enabled: boolean) => ({ +const getSettings = (enabled: boolean, flow: string) => ({ auth: { enabled, + flow, options: [], }, baseUrl: 'www.base.com', @@ -29,16 +33,40 @@ const getSettings = (enabled: boolean) => ({ }); describe('isAuthorized', () => { - it('should return true if auth no enabled and no user', () => { - expect(isAuthorized(getSettings(false), noUser)).toBe(true); + it('should return true if auth not enabled and no user', () => { + expect(isAuthorized(getSettings(false, 'authorization-code'), noUser)).toBe( + true, + ); + }); + it('should return true if auth not enabled and user', () => { + expect( + isAuthorized(getSettings(false, 'authorization-code'), codeUser), + ).toBe(true); + }); + it('should return false if code auth enabled and no user', () => { + expect(isAuthorized(getSettings(true, 'authorization-code'), noUser)).toBe( + false, + ); + }); + it('should return false if code auth enabled and implicit user', () => { + expect( + isAuthorized(getSettings(true, 'authorization-code'), implicitUser), + ).toBe(false); + }); + it('should return true if code auth enabled and code user', () => { + expect( + isAuthorized(getSettings(true, 'authorization-code'), codeUser), + ).toBe(true); }); - it('should return true if auth no enabled and user', () => { - expect(isAuthorized(getSettings(false), user)).toBe(true); + it('should return false if implicit auth enabled and no user', () => { + expect(isAuthorized(getSettings(true, 'implicit'), noUser)).toBe(false); }); - it('should return false if auth enabled and no user', () => { - expect(isAuthorized(getSettings(true), noUser)).toBe(false); + it('should return false if implicit auth enabled and implicit user', () => { + expect(isAuthorized(getSettings(true, 'implicit'), codeUser)).toBe(false); }); - it('should return true if auth enabled and user', () => { - expect(isAuthorized(getSettings(true), user)).toBe(true); + it('should return true if implicit auth enabled and implicit user', () => { + expect(isAuthorized(getSettings(true, 'implicit'), implicitUser)).toBe( + true, + ); }); }); diff --git a/src/lib/utilities/is-authorized.ts b/src/lib/utilities/is-authorized.ts index 7b1b06b91..bed977276 100644 --- a/src/lib/utilities/is-authorized.ts +++ b/src/lib/utilities/is-authorized.ts @@ -1,5 +1,12 @@ import type { Settings, User } from '$lib/types/global'; +import { OIDCFlow } from '$lib/types/global'; export const isAuthorized = (settings: Settings, user: User): boolean => { - return !settings.auth.enabled || Boolean(user?.accessToken); + return ( + !settings.auth.enabled || + Boolean( + (settings.auth.flow == OIDCFlow.AuthorizationCode && user?.accessToken) || + (settings.auth.flow == OIDCFlow.Implicit && user?.idToken), + ) + ); }; diff --git a/src/lib/utilities/request-from-api.test.ts b/src/lib/utilities/request-from-api.test.ts index 3f7c3fcf4..1bda98aed 100644 --- a/src/lib/utilities/request-from-api.test.ts +++ b/src/lib/utilities/request-from-api.test.ts @@ -110,7 +110,7 @@ describe('requestFromAPI', () => { }); }); - it('should not add csrf cookie to headers if not presdent', async () => { + it('should not add csrf cookie to headers if not present', async () => { const token = 'token'; const request = fetchMock(); diff --git a/src/lib/utilities/request-from-api.ts b/src/lib/utilities/request-from-api.ts index 36661fa17..7fb16b9d4 100644 --- a/src/lib/utilities/request-from-api.ts +++ b/src/lib/utilities/request-from-api.ts @@ -129,25 +129,25 @@ const withAuth = async ( options: RequestInit, isBrowser = BROWSER, ): Promise => { + const { accessToken, idToken } = getAuthUser(); if (globalThis?.AccessToken) { options.headers = await withBearerToken( options?.headers, globalThis.AccessToken, isBrowser, ); - } else if (getAuthUser().accessToken) { + } else if (accessToken || idToken) { options.headers = await withBearerToken( options?.headers, - async () => getAuthUser().accessToken, - isBrowser, - ); - options.headers = withIdToken( - options?.headers, - getAuthUser().idToken, + async () => accessToken ?? idToken, isBrowser, ); } + if (idToken) { + options.headers = withIdToken(options?.headers, idToken, isBrowser); + } + return options; }; diff --git a/src/lib/utilities/route-for.test.ts b/src/lib/utilities/route-for.test.ts index 939372f9e..9d5db4b4c 100644 --- a/src/lib/utilities/route-for.test.ts +++ b/src/lib/utilities/route-for.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import { hasParameters, @@ -6,6 +6,7 @@ import { isEventParameters, isNamespaceParameter, isWorkflowParameters, + maybeRouteForOIDCImplicitCallback, routeForArchivalWorkfows, routeForAuthentication, routeForCallStack, @@ -172,6 +173,7 @@ describe('routeFor SSO authentication ', () => { it('Options added through settings should be passed in the url', () => { const settings = { auth: { + flow: 'authorization-code', options: ['one'], }, baseUrl: 'https://localhost/', @@ -192,6 +194,7 @@ describe('routeFor SSO authentication ', () => { it('should fallback to the originUrl if returnUrl is not provided', () => { const settings = { auth: { + flow: 'authorization-code', options: ['one'], }, baseUrl: 'https://localhost/', @@ -210,6 +213,7 @@ describe('routeFor SSO authentication ', () => { it('should use the returnUrl if provided', () => { const settings = { auth: { + flow: 'authorization-code', options: ['one'], }, baseUrl: 'https://localhost/', @@ -229,6 +233,7 @@ describe('routeFor SSO authentication ', () => { it("should not add the options from the search param if they don't exist in the current url params", () => { const settings = { auth: { + flow: 'authorization-code', options: ['one'], }, baseUrl: 'https://localhost/', @@ -245,7 +250,10 @@ describe('routeFor SSO authentication ', () => { }); it('Should render a login url', () => { - const settings = { auth: {}, baseUrl: 'https://localhost' }; + const settings = { + auth: { flow: 'authorization-code' }, + baseUrl: 'https://localhost', + }; const searchParams = new URLSearchParams(); const sso = routeForAuthentication({ settings, searchParams }); @@ -254,7 +262,10 @@ describe('routeFor SSO authentication ', () => { }); it('Should add return URL search param', () => { - const settings = { auth: {}, baseUrl: 'https://localhost' }; + const settings = { + auth: { flow: 'authorization-code' }, + baseUrl: 'https://localhost', + }; const searchParams = new URLSearchParams(); searchParams.set('returnUrl', 'https://localhost/some/path'); @@ -271,7 +282,10 @@ describe('routeFor SSO authentication ', () => { }); it('Should not add return URL search param if undefined', () => { - const settings = { auth: {}, baseUrl: 'https://localhost' }; + const settings = { + auth: { flow: 'authorization-code' }, + baseUrl: 'https://localhost', + }; const searchParams = new URLSearchParams(); const sso = routeForAuthentication({ settings, searchParams }); @@ -283,6 +297,7 @@ describe('routeFor SSO authentication ', () => { it('test of the signin flow', () => { const settings = { auth: { + flow: 'authorization-code', options: ['organization_name', 'invitation'], }, baseUrl: 'https://localhost/', @@ -302,6 +317,174 @@ describe('routeFor SSO authentication ', () => { ); }); + describe('implicit oidc flow', () => { + it('should add a nonce', () => { + const settings = { + auth: { + flow: 'implicit', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email', 'profile'], + }, + baseUrl: 'https://localhost', + }; + + const searchParams = new URLSearchParams(); + + const sso = routeForAuthentication({ + settings, + searchParams, + }); + + const ssoUrl = new URL(sso); + expect(window.localStorage.getItem('nonce')).toBe( + ssoUrl.searchParams.get('nonce'), + ); + window.localStorage.removeItem('nonce'); + }); + + it('should manage state', () => { + const settings = { + auth: { + flow: 'implicit', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + scopes: ['openid', 'email', 'profile'], + }, + baseUrl: 'https://localhost', + }; + + const searchParams = new URLSearchParams(); + searchParams.set('returnUrl', 'https://localhost/some/path'); + + const sso = routeForAuthentication({ + settings, + searchParams, + }); + + const ssoUrlStateKey = new URL(sso).searchParams.get('state'); + expect(ssoUrlStateKey).not.toBeNull(); + expect(window.sessionStorage.getItem(ssoUrlStateKey as string)).toBe( + 'https://localhost/some/path', + ); + window.sessionStorage.removeItem(ssoUrlStateKey as string); + }); + + describe('routeFor oidc implicit callback', () => { + it('should return null if passed an empty hash', () => { + expect(maybeRouteForOIDCImplicitCallback('#')).toBeNull(); + }); + + it('should return null if no ID token in the hash', () => { + const params = new URLSearchParams({ foo: 'bar', biz: 'baz' }); + expect(maybeRouteForOIDCImplicitCallback(`#${params}`)).toBeNull(); + }); + + it('should throw if invalid ID token in the hash', () => { + const params = new URLSearchParams({ + foo: 'bar', + biz: 'baz', + id_token: 'scrooge-mcduck', + }); + expect(() => maybeRouteForOIDCImplicitCallback(`#${params}`)).toThrow( + 'Invalid id_token in hash', + ); + }); + + /* + * tokens created from https://jwt.io. can be decoded edited and reencoded from there + */ + it('should throw if the nonce is missing from the token', () => { + localStorage.setItem('nonce', 'foobar'); + const params = new URLSearchParams({ + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjJ9.JXIgh2oYQw3Sk8NQL3e89jqaPF8LX4bt1KyrkqeOFx4', + }); + + expect(() => maybeRouteForOIDCImplicitCallback(`#${params}`)).toThrow( + 'No nonce in token', + ); + localStorage.removeItem('nonce'); + }); + + it('should throw if the nonce is mismatched', () => { + localStorage.setItem('nonce', 'foobar'); + const params = new URLSearchParams({ + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjIsIm5vbmNlIjoiYml6YmF6In0.NZa8yiSta4lRnemoY9M45ErqluvAPtN12JmRGZAECnY', + }); + expect(() => maybeRouteForOIDCImplicitCallback(`#${params}`)).toThrow( + 'Mismatched nonces', + ); + localStorage.removeItem('nonce'); + }); + + it('should process the hash into the returned callback struct', () => { + localStorage.setItem('nonce', 'denim-jacket'); + sessionStorage.setItem( + 'roper-boots', + 'https://nationalcowboymuseum.org/plan-your-visit/', + ); // state + + const params = new URLSearchParams({ + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjIsImVtYWlsIjoiZGVyZWtAZGlld29ya3dlYXIuY29tIiwibm9uY2UiOiJkZW5pbS1qYWNrZXQifQ.wG64FRrUCoHrQC4wASodyO7_3eeOeUx6myM0QvEKNk4', + state: 'roper-boots', + }); + const callback = maybeRouteForOIDCImplicitCallback(`#${params}`); + expect(callback).not.toBeNull(); + if (callback === null) { + assert.fail('failed to process a valid hash'); + } + + expect + .soft(callback.redirectUrl) + .toBe('https://nationalcowboymuseum.org/plan-your-visit/'); + + expect.soft(callback.authUser).toEqual({ + idToken: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjIsImVtYWlsIjoiZGVyZWtAZGlld29ya3dlYXIuY29tIiwibm9uY2UiOiJkZW5pbS1qYWNrZXQifQ.wG64FRrUCoHrQC4wASodyO7_3eeOeUx6myM0QvEKNk4', + name: 'Derek Guy', + email: 'derek@dieworkwear.com', + }); + + expect.soft(callback.stateKey).toBe('roper-boots'); + + localStorage.removeItem('nonce'); + sessionStorage.removeItem('roper-boots'); + }); + + it('should throw if the hash state key is missing from session storage', () => { + localStorage.setItem('nonce', 'denim-jacket'); + + const params = new URLSearchParams({ + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjIsImVtYWlsIjoiZGVyZWtAZGlld29ya3dlYXIuY29tIiwibm9uY2UiOiJkZW5pbS1qYWNrZXQifQ.wG64FRrUCoHrQC4wASodyO7_3eeOeUx6myM0QvEKNk4', + }); + + expect(() => maybeRouteForOIDCImplicitCallback(`#${params}`)).toThrow( + 'No state in hash', + ); + + localStorage.removeItem('nonce'); + }); + + it('should throw if the hash state key is missing from the hash', () => { + localStorage.setItem('nonce', 'denim-jacket'); + + const params = new URLSearchParams({ + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWV3b3Jrd2VhciIsIm5hbWUiOiJEZXJlayBHdXkiLCJpYXQiOjE1MTYyMzkwMjIsImVtYWlsIjoiZGVyZWtAZGlld29ya3dlYXIuY29tIiwibm9uY2UiOiJkZW5pbS1qYWNrZXQifQ.wG64FRrUCoHrQC4wASodyO7_3eeOeUx6myM0QvEKNk4', + state: 'western-boots', + }); + + expect(() => maybeRouteForOIDCImplicitCallback(`#${params}`)).toThrow( + 'Hash state missing from sessionStorage', + ); + + localStorage.removeItem('nonce'); + }); + }); + }); + describe('routeForLoginPage', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts index 7a893a177..7495596e1 100644 --- a/src/lib/utilities/route-for.ts +++ b/src/lib/utilities/route-for.ts @@ -1,9 +1,11 @@ import { BROWSER } from 'esm-env'; +import { InvalidTokenError, jwtDecode, type JwtPayload } from 'jwt-decode'; import { base } from '$app/paths'; import type { EventView } from '$lib/types/events'; -import type { Settings } from '$lib/types/global'; +import type { User } from '$lib/types/global'; +import { OIDCFlow, type Settings } from '$lib/types/global'; import { encodeURIForSvelte } from '$lib/utilities/encode-uri'; import { toURL } from '$lib/utilities/to-url'; @@ -196,7 +198,24 @@ export const routeForAuthentication = ( parameters: AuthenticationParameters, ): string => { const { settings, searchParams: currentSearchParams, originUrl } = parameters; + switch (settings.auth.flow) { + case OIDCFlow.AuthorizationCode: + default: + return routeForAuthorizationCodeFlow( + settings, + currentSearchParams, + originUrl, + ); + case OIDCFlow.Implicit: + return routeForImplicitFlow(settings, currentSearchParams, originUrl); + } +}; +const routeForAuthorizationCodeFlow = ( + settings: Settings, + currentSearchParams: URLSearchParams, + originUrl: string, +) => { const login = new URL(`${base}/auth/sso`, settings.baseUrl); let opts = settings.auth.options ?? []; @@ -217,6 +236,118 @@ export const routeForAuthentication = ( return login.toString(); }; +/** + * + * @returns URL for the SSO redirect + * @modifies adds items to browser localStorage and sessionStorage + * + */ +const routeForImplicitFlow = ( + settings: Settings, + currentSearchParams: URLSearchParams, + originUrl: string, +): string => { + const authorizationUrl = new URL(settings.auth.authorizationUrl); + authorizationUrl.searchParams.set('response_type', 'id_token'); + authorizationUrl.searchParams.set('client_id', settings.auth.clientId); + authorizationUrl.searchParams.set('redirect_uri', settings.auth.callbackUrl); + authorizationUrl.searchParams.set('scope', settings.auth.scopes.join(' ')); + + const nonce = crypto.randomUUID(); + window.localStorage.setItem('nonce', nonce); + authorizationUrl.searchParams.set('nonce', nonce); + + // state stores a reference to the redirect path + const state = crypto.randomUUID(); + sessionStorage.setItem( + state, + currentSearchParams.get('returnUrl') ?? (originUrl || '/'), + ); + authorizationUrl.searchParams.set('state', state); + + return authorizationUrl.toString(); +}; + +export type OIDCCallback = { + redirectUrl: string; + authUser: User; + stateKey: string; +}; + +export class OIDCImplicitCallbackError extends Error {} +export class OIDCImplicitCallbackNonceError extends OIDCImplicitCallbackError {} +export class OIDCImplicitCallbackStateError extends OIDCImplicitCallbackError {} + +/** + * takes a hash string attempts to parse it as a callback for the OIDC implicit flow + * + * @returns return URL from the SSO redirect and the user's auth data. null if hash is not for OIDC implicit flow + * @throws {OIDCImplicitCallbackError} when an invalid id_token param is found + * + */ +export const maybeRouteForOIDCImplicitCallback = ( + rawHash: string, +): OIDCCallback | null => { + const hash = new URLSearchParams(rawHash.substring(1)); + + interface OIDCImplicitJwtPayload extends JwtPayload { + nonce?: string; + name?: string; + email?: string; + } + + const rawIdToken = hash.get('id_token'); + if (!rawIdToken) { + return null; + } + + const nonce = window.localStorage.getItem('nonce'); + if (!nonce) { + throw new OIDCImplicitCallbackNonceError('No nonce in localStorage'); + } + + let token: OIDCImplicitJwtPayload; + try { + // validation is delegated to the cluster's ClaimMapper plugin + token = jwtDecode(rawIdToken); + } catch (e) { + if (e instanceof InvalidTokenError) { + throw new OIDCImplicitCallbackError('Invalid id_token in hash'); + } else { + throw new OIDCImplicitCallbackError(e); + } + } + + // TODO: support optional issuer validation with settings.auth.issuerUrl and token.iss + + if (!token.nonce) { + throw new OIDCImplicitCallbackNonceError('No nonce in token'); + } else if (token.nonce !== nonce) { + throw new OIDCImplicitCallbackNonceError('Mismatched nonces'); + } + + const stateKey = hash.get('state'); + if (!stateKey) { + throw new OIDCImplicitCallbackStateError('No state in hash'); + } + const redirectUrl = sessionStorage.getItem(stateKey); + if (!redirectUrl) { + throw new OIDCImplicitCallbackStateError( + 'Hash state missing from sessionStorage', + ); + } + + return { + redirectUrl: redirectUrl, + authUser: { + idToken: rawIdToken, + name: token.name, + email: token.email, + }, + stateKey: stateKey, + }; +}; + export const routeForLoginPage = (error = '', isBrowser = BROWSER): string => { if (isBrowser) { const login = new URL(`${base}/login`, window.location.origin); diff --git a/src/routes/(app)/+layout.ts b/src/routes/(app)/+layout.ts index 8552c57a0..ebf3b1d6a 100644 --- a/src/routes/(app)/+layout.ts +++ b/src/routes/(app)/+layout.ts @@ -8,15 +8,28 @@ import { fetchSettings } from '$lib/services/settings-service'; import { clearAuthUser, getAuthUser, setAuthUser } from '$lib/stores/auth-user'; import type { GetClusterInfoResponse, GetSystemInfoResponse } from '$lib/types'; import type { Settings } from '$lib/types/global'; +import { OIDCFlow } from '$lib/types/global'; import { cleanAuthUserCookie, getAuthUserCookie, } from '$lib/utilities/auth-user-cookie'; import { isAuthorized } from '$lib/utilities/is-authorized'; -import { routeForLoginPage } from '$lib/utilities/route-for'; +import { + maybeRouteForOIDCImplicitCallback, + type OIDCCallback, + OIDCImplicitCallbackError, + routeForLoginPage, +} from '$lib/utilities/route-for'; import '../../app.css'; +/** + * + * @modifies removes the nonce from localStorage and the state from sessionStorage + * @modifies drops the url hash fragment + * @throws {Redirect} to address auth and login state + * + */ export const load: LayoutLoad = async function ({ fetch, }): Promise { @@ -27,10 +40,41 @@ export const load: LayoutLoad = async function ({ clearAuthUser(); } - const authUser = getAuthUserCookie(); - if (authUser?.accessToken) { - setAuthUser(authUser); - cleanAuthUserCookie(); + if (settings.auth.flow == OIDCFlow.AuthorizationCode) { + const authUser = getAuthUserCookie(); + if (authUser?.accessToken) { + setAuthUser(authUser, settings.auth.flow); + cleanAuthUserCookie(); + } + } else if (settings.auth.flow == OIDCFlow.Implicit && window.location.hash) { + let callback: OIDCCallback; + try { + console.log(window.location.hash); + callback = maybeRouteForOIDCImplicitCallback(window.location.hash); + } catch (e) { + if (e instanceof OIDCImplicitCallbackError) { + clearHash(); + } else { + throw e; + } + } + + if (callback) { + clearHash(); + const { redirectUrl: url, authUser, stateKey } = callback; + + setAuthUser(authUser, settings.auth.flow); + localStorage.removeItem('nonce'); + if (stateKey) { + sessionStorage.removeItem(stateKey); + } + + redirect(302, url); + } + } + + if (!settings.auth.enabled) { + clearAuthUser(); // prevent stale local storage values being passed down } const user = getAuthUser(); @@ -54,3 +98,17 @@ export const load: LayoutLoad = async function ({ systemInfo, }; }; + +/** + * + * @modifies drops the url hash + * + */ +function clearHash() { + // known oidc sveltekit issue https://github.com/sveltejs/kit/issues/7271 + history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ); +}