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..3e1427956 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,119 @@ 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
+ * @throws {OIDCImplicitCallbackNonceError, OIDCImplicitCallbackStateError} when inconsistencies in the nonce or state fail security checks
+ *
+ */
+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..6d82e631e 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,40 @@ 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 {
+ 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 +97,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,
+ );
+}