From c548516db8319d04aa8084356599ba3178e81e05 Mon Sep 17 00:00:00 2001 From: Brendan Gerrity Date: Fri, 23 Feb 2024 17:47:42 -0500 Subject: [PATCH] add oidc implicit flow support --- 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 | 23 +++++- server/server/config/config.go | 14 ++-- server/server/route/auth.go | 47 +++++++---- src/lib/services/settings-service.ts | 7 ++ src/lib/stores/auth-user.ts | 16 +++- src/lib/types/global.ts | 12 +++ src/lib/types/index.ts | 14 +++- src/lib/utilities/is-authorized.ts | 9 +- src/lib/utilities/request-from-api.ts | 12 ++- src/lib/utilities/route-for.ts | 113 +++++++++++++++++++++++++- src/routes/(app)/+layout.ts | 45 ++++++++-- 15 files changed, 305 insertions(+), 50 deletions(-) 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..cc1e33cc3 100644 --- a/server/server/config/auth.go +++ b/server/server/config/auth.go @@ -52,8 +52,27 @@ 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": + 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 477ef4444..4ad5436ca 100644 --- a/server/server/config/config.go +++ b/server/server/config/config.go @@ -97,12 +97,16 @@ type ( 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"` - // 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"` // URL for the callback, e.g. https://localhost:8080/sso/callback diff --git a/server/server/route/auth.go b/server/server/route/auth.go index b314d045a..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/services/settings-service.ts b/src/lib/services/settings-service.ts index 55974b29b..e7bf68165 100644 --- a/src/lib/services/settings-service.ts +++ b/src/lib/services/settings-service.ts @@ -20,6 +20,13 @@ export const fetchSettings = async (request = fetch): Promise => { 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..300e37ade 100644 --- a/src/lib/stores/auth-user.ts +++ b/src/lib/stores/auth-user.ts @@ -2,16 +2,26 @@ 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: + 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.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.ts b/src/lib/utilities/request-from-api.ts index 092af3e83..449fe3cf4 100644 --- a/src/lib/utilities/request-from-api.ts +++ b/src/lib/utilities/request-from-api.ts @@ -126,17 +126,15 @@ const withAuth = async ( options: RequestInit, isBrowser = BROWSER, ): Promise => { - if (getAuthUser().accessToken) { + const authUser = getAuthUser(); + if (authUser) { + const { accessToken, idToken } = authUser; options.headers = await withBearerToken( options?.headers, - async () => getAuthUser().accessToken, - isBrowser, - ); - options.headers = withIdToken( - options?.headers, - getAuthUser().idToken, + async () => accessToken ?? idToken, isBrowser, ); + options.headers = withIdToken(options?.headers, idToken, isBrowser); } else if (globalThis?.AccessToken) { options.headers = await withBearerToken( options?.headers, diff --git a/src/lib/utilities/route-for.ts b/src/lib/utilities/route-for.ts index 95179717e..fe50b5bf5 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, JwtPayload } from 'jwt-decode'; import { base } from '$app/paths'; +import { setAuthUser } from '$lib/stores/auth-user'; import type { EventView } from '$lib/types/events'; -import type { Settings } 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'; @@ -184,7 +186,23 @@ export const routeForAuthentication = ( parameters: AuthenticationParameters, ): string => { const { settings, searchParams: currentSearchParams, originUrl } = parameters; + switch (settings.auth.flow) { + case OIDCFlow.AuthorizationCode: + return routeForAuthorizationCodeFlow( + settings, + currentSearchParams, + originUrl, + ); + case OIDCFlow.Implicit: + return routeForImplicitFlow(settings); + } +}; +const routeForAuthorizationCodeFlow = ( + settings: Settings, + currentSearchParams: URLSearchParams, + originUrl: string, +) => { const login = new URL(`${base}/auth/sso`, settings.baseUrl); let opts = settings.auth.options ?? []; @@ -205,6 +223,99 @@ export const routeForAuthentication = ( return login.toString(); }; +/** + * + * @returns URL for the SSO redirect + * @modifies adds items to browser localStorage and sessionStorage + * + */ +const routeForImplicitFlow = (settings: Settings): 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, + new URLSearchParams(window.location.search).get('returnUrl') ?? '/', + ); + authorizationUrl.searchParams.set('state', state); + + return authorizationUrl.toString(); +}; + +export class OIDCImplicitCallbackError extends Error {} + +/** + * + * @returns return URL from the SSO redirect + * @throws {OIDCImplicitCallbackError} + * @modifies removes items from browser localStorage and sessionStorage + * + */ +export const routeForOIDCImplicitCallback = (): string => { + const hash = new URLSearchParams(window.location.hash.substring(1)); + + interface OIDCImplicitJwtPayload extends JwtPayload { + nonce?: string; + name?: string; + email?: string; + } + + const rawIdToken = hash.get('id_token'); + if (!rawIdToken) { + throw new OIDCImplicitCallbackError('No id_token in hash'); + } + + const nonce = window.localStorage.getItem('nonce'); + window.localStorage.removeItem('nonce'); + if (!nonce) { + throw new OIDCImplicitCallbackError('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 OIDCImplicitCallbackError('No nonce in token'); + } else if (token.nonce !== nonce) { + throw new OIDCImplicitCallbackError('Mismatched nonces'); + } + + const state = hash.get('state'); + const redirectUrl = new URL(sessionStorage.getItem(state) ?? '/'); + sessionStorage.removeItem('state'); + + setAuthUser( + { + idToken: rawIdToken, + name: token.name, + email: token.email, + }, + OIDCFlow.Implicit, + ); + + return redirectUrl.toString(); +}; + 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 23595c788..af498dacb 100644 --- a/src/routes/(app)/+layout.ts +++ b/src/routes/(app)/+layout.ts @@ -8,24 +8,59 @@ import { fetchSettings } from '$lib/services/settings-service'; import { 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 { + OIDCImplicitCallbackError, + routeForLoginPage, + routeForOIDCImplicitCallback, +} from '$lib/utilities/route-for'; import '../../app.css'; +/** + * + * @modifies removes items from browser localStorage and sessionStorage + * @modifies url hash + * @throws {Redirect} to address auth and login state + * + */ export const load: LayoutLoad = async function ({ fetch, }): Promise { const settings: Settings = await fetchSettings(fetch); - 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 route: string; + try { + route = routeForOIDCImplicitCallback(); + } catch (e) { + if (!(e instanceof OIDCImplicitCallbackError)) { + throw e; + } + } finally { + // side effect: clear the hash from the URL + // known oidc sveltekit issue https://github.com/sveltejs/kit/issues/7271 + history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ); + } + + if (route) { + throw redirect(302, route); + } } const user = getAuthUser();