From e0256ef8ce3f70b162fcf2b5f4f3da481467c666 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 11:48:35 +0800 Subject: [PATCH 01/20] Update @authgear/web to latest version --- portal/package-lock.json | 14 +++++++------- portal/package.json | 2 +- .../graphql/portal/AcceptAdminInvitationScreen.tsx | 4 ++-- portal/src/graphql/portal/Authenticated.tsx | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/portal/package-lock.json b/portal/package-lock.json index 967c1edb99..154fc4736d 100644 --- a/portal/package-lock.json +++ b/portal/package-lock.json @@ -11,7 +11,7 @@ ], "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "1.0.1", + "@authgear/web": "^2.11.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", @@ -281,9 +281,9 @@ } }, "node_modules/@authgear/web": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-1.0.1.tgz", - "integrity": "sha512-FgRVqyIviqRKWpJgJpKqLFSVkYN2R/CIXudGsP87yMFOKg8UPzPDnAUOs7D9hlXc/8anvysyglhjul3TwD0klg==" + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.11.0.tgz", + "integrity": "sha512-NvWHEBdk09+KNAMyhz5rZZkgzIDbizgHj0jprVVNZ3upqeLwlcR5McLOPfiGPE8kAnLhJz6QEJDPZT0eaKkXVA==" }, "node_modules/@babel/code-frame": { "version": "7.25.7", @@ -17216,9 +17216,9 @@ } }, "@authgear/web": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-1.0.1.tgz", - "integrity": "sha512-FgRVqyIviqRKWpJgJpKqLFSVkYN2R/CIXudGsP87yMFOKg8UPzPDnAUOs7D9hlXc/8anvysyglhjul3TwD0klg==" + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.11.0.tgz", + "integrity": "sha512-NvWHEBdk09+KNAMyhz5rZZkgzIDbizgHj0jprVVNZ3upqeLwlcR5McLOPfiGPE8kAnLhJz6QEJDPZT0eaKkXVA==" }, "@babel/code-frame": { "version": "7.25.7", diff --git a/portal/package.json b/portal/package.json index bf53d3871b..a892d2f1c3 100644 --- a/portal/package.json +++ b/portal/package.json @@ -59,7 +59,7 @@ }, "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "1.0.1", + "@authgear/web": "^2.11.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", diff --git a/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx b/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx index f60b3d0cdd..6528fd797b 100644 --- a/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx +++ b/portal/src/graphql/portal/AcceptAdminInvitationScreen.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useContext, useMemo } from "react"; -import authgear from "@authgear/web"; +import authgear, { PromptOption } from "@authgear/web"; import { Text, DefaultEffects } from "@fluentui/react"; import { Context, @@ -175,7 +175,7 @@ const AcceptAdminInvitationScreen: React.VFC = authgear .startAuthentication({ redirectURI, - prompt: "login", + prompt: PromptOption.Login, state: encodeOAuthState({ originalPath, }), diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index aa2adf6b00..0182363a94 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import authgear from "@authgear/web"; +import authgear, { PromptOption } from "@authgear/web"; import { useNavigate } from "react-router-dom"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; @@ -31,7 +31,7 @@ const ShowQueryResult: React.VFC = authgear .startAuthentication({ redirectURI, - prompt: "login", + prompt: PromptOption.Login, state: encodeOAuthState({ originalPath, }), From 6ef0119a8ed7fdb421f89a1d318b4e2a5aab80ba Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 9 Jan 2025 19:19:17 +0800 Subject: [PATCH 02/20] Use accounts.portal.localhost:3100 to access accounts --- CONTRIBUTING.md | 6 ++++++ nginx.conf | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f679ec0387..4d89e6f2bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -212,6 +212,12 @@ use flake go run ./cmd/authgear search database migrate up ``` +3. Add domain + + ``` + go run ./cmd/portal internal domain create-custom accounts --apex-domain="accounts.portal.localhost" --domain="accounts.portal.localhost" + ``` + ## Set up MinIO ```sh diff --git a/nginx.conf b/nginx.conf index 40eb930266..9df115ee21 100644 --- a/nginx.conf +++ b/nginx.conf @@ -74,7 +74,7 @@ http { proxy_pass http://host.docker.internal:3001/resolve; proxy_pass_request_body off; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host "accounts.localhost"; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; proxy_set_header Content-Length ""; } } @@ -121,7 +121,7 @@ http { proxy_pass http://host.docker.internal:3001/resolve; proxy_pass_request_body off; proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-Host "accounts.localhost"; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; proxy_set_header Content-Length ""; } } From 18ebe64b47038e78446b86f0146deefcc82b176c Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 9 Jan 2025 19:19:54 +0800 Subject: [PATCH 03/20] Update the config of portal application to be compatible with refresh token --- CONTRIBUTING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d89e6f2bb..87ab9aadb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,11 @@ use flake client_id: portal # Note that the trailing slash is very important here # URIs are compared byte by byte. + refresh_token_lifetime_seconds: 86400 + refresh_token_idle_timeout_enabled: true + refresh_token_idle_timeout_seconds: 1800 + issue_jwt_access_token: true + access_token_lifetime_seconds: 900 redirect_uris: # This redirect URI is used by the portal development server. - "http://portal.localhost:8000/oauth-redirect" @@ -177,9 +182,6 @@ use flake - "http://portal.localhost:8000/" # This redirect URI is used by the portal production build. - "http://portal.localhost:8010/" - grant_types: [] - response_types: - - none ``` 3. Set up `.localhost` From 4f04372f66832db6182099730b335c21b5d291ed Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 9 Jan 2025 19:30:57 +0800 Subject: [PATCH 04/20] Make network calls compatible with refresh token --- portal/src/graphql/adminapi/EditPictureScreen.tsx | 12 +++++++++++- portal/src/graphql/adminapi/apollo.ts | 2 ++ portal/src/graphql/portal/apollo.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/portal/src/graphql/adminapi/EditPictureScreen.tsx b/portal/src/graphql/adminapi/EditPictureScreen.tsx index 05c42fd27e..c762c3b745 100644 --- a/portal/src/graphql/adminapi/EditPictureScreen.tsx +++ b/portal/src/graphql/adminapi/EditPictureScreen.tsx @@ -10,7 +10,8 @@ import React, { import { FormattedMessage, Context } from "@oursky/react-messageformat"; import { Dialog, DialogFooter, Spinner, SpinnerSize } from "@fluentui/react"; import { useParams, useNavigate } from "react-router-dom"; -import axios, { AxiosProgressEvent } from "axios"; +import axios, { AxiosProgressEvent, RawAxiosRequestHeaders } from "axios"; +import authgear from "@authgear/web"; import PrimaryButton from "../../PrimaryButton"; import DefaultButton from "../../DefaultButton"; import { FormProvider } from "../../form"; @@ -258,8 +259,16 @@ function EditPictureScreenContent(props: EditPictureScreenContentProps) { percentComplete: undefined, }); + await authgear.refreshAccessTokenIfNeeded(); + + const headers: RawAxiosRequestHeaders = {}; + if (authgear.accessToken != null) { + headers.Authorization = `Bearer ${authgear.accessToken}`; + } + const resp = await axios(`/api/apps/${appID}/_api/admin/images/upload`, { method: "GET", + headers, onUploadProgress: onProgress, onDownloadProgress: onProgress, }); @@ -269,6 +278,7 @@ function EditPictureScreenContent(props: EditPictureScreenContentProps) { formData.append("file", blob); const uploadResp = await axios(upload_url, { method: "POST", + headers, data: formData, onUploadProgress: onProgress, onDownloadProgress: onProgress, diff --git a/portal/src/graphql/adminapi/apollo.ts b/portal/src/graphql/adminapi/apollo.ts index 63eecee394..6a89525314 100644 --- a/portal/src/graphql/adminapi/apollo.ts +++ b/portal/src/graphql/adminapi/apollo.ts @@ -1,4 +1,5 @@ import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; +import authgear from "@authgear/web"; import { createLogoutLink } from "../portal/apollo"; export function makeGraphQLEndpoint(graphqlOpaqueAppID: string): string { @@ -11,6 +12,7 @@ export function makeClient( ): ApolloClient { const httpLink = new HttpLink({ uri: makeGraphQLEndpoint(graphqlOpaqueAppID), + fetch: authgear.fetch.bind(authgear), }); const logoutLink = createLogoutLink(() => { onLogout(); diff --git a/portal/src/graphql/portal/apollo.ts b/portal/src/graphql/portal/apollo.ts index f431e62e92..b4d71578c5 100644 --- a/portal/src/graphql/portal/apollo.ts +++ b/portal/src/graphql/portal/apollo.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from "react"; +import authgear from "@authgear/web"; import { ApolloCache, ApolloClient, @@ -77,7 +78,10 @@ export function createClient(options: { onLogout: () => void; }): ApolloClient { const { cache } = options; - const httpLink = new HttpLink({ uri: "/api/graphql" }); + const httpLink = new HttpLink({ + uri: "/api/graphql", + fetch: authgear.fetch.bind(authgear), + }); return new ApolloClient({ link: createLogoutLink(options.onLogout).concat(httpLink), From 79190dcd6a8bc51f447797d7b212d04eeb85eecf Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Thu, 9 Jan 2025 19:47:34 +0800 Subject: [PATCH 05/20] Move logout to Authenticated.tsx --- portal/src/ScreenHeader.tsx | 22 +++++++-------------- portal/src/graphql/portal/Authenticated.tsx | 15 +++++++++++++- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/portal/src/ScreenHeader.tsx b/portal/src/ScreenHeader.tsx index d7a203b2f8..eb836a99a0 100644 --- a/portal/src/ScreenHeader.tsx +++ b/portal/src/ScreenHeader.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useContext, useMemo } from "react"; import { useParams } from "react-router-dom"; import { Context } from "@oursky/react-messageformat"; -import authgear from "@authgear/web"; import { Icon, Text, @@ -21,7 +20,8 @@ import styles from "./ScreenHeader.module.css"; import { useSystemConfig } from "./context/SystemConfigContext"; import { useBoolean } from "@fluentui/react-hooks"; import ExternalLink from "./ExternalLink"; -import { useCapture, useReset } from "./gtm_v2"; +import { useLogout } from "./graphql/portal/Authenticated"; +import { useCapture } from "./gtm_v2"; interface LogoProps { isNavbarHeader?: boolean; @@ -182,22 +182,14 @@ const ScreenHeader: React.VFC = function ScreenHeader(props) { const { viewer } = useViewerQuery(); const [isNavbarOpen, { setTrue: openNavbar, setFalse: dismissNavbar }] = useBoolean(false); - const reset = useReset(); - const redirectURI = window.location.origin + "/"; + const logout = useLogout(); const onClickLogout = useCallback(() => { - authgear - .logout({ - redirectURI, - }) - .then(() => { - reset(); - }) - .catch((err) => { - console.error(err); - }); - }, [redirectURI, reset]); + logout().catch((err: unknown) => { + console.error(err); + }); + }, [logout]); const onClickCookiePreference = useCallback(() => { if (window.Osano?.cm !== undefined) { diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index 0182363a94..68f8e4e6b0 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -1,10 +1,11 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useCallback } from "react"; import authgear, { PromptOption } from "@authgear/web"; import { useNavigate } from "react-router-dom"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; import { useViewerQuery } from "./query/viewerQuery"; import { InternalRedirectState } from "../../InternalRedirect"; +import { useReset } from "../../gtm_v2"; interface ShowQueryResultProps { isAuthenticated: boolean; @@ -101,4 +102,16 @@ export async function startReauthentication( }); } +export function useLogout(): () => Promise { + const redirectURI = window.location.origin + "/"; + const reset = useReset(); + const logout = useCallback(async () => { + await authgear.logout({ + redirectURI, + }); + reset(); + }, [redirectURI, reset]); + return logout; +} + export default Authenticated; From 11afbdd62fb5401901eb7ced3ed67c9a75fd5483 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 11:54:00 +0800 Subject: [PATCH 06/20] Move configureAuthgear to Authenticated.tsx --- portal/src/ReactApp.tsx | 14 +++++++------- portal/src/graphql/portal/Authenticated.tsx | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/portal/src/ReactApp.tsx b/portal/src/ReactApp.tsx index 4d816fdb0c..d854d6ee8f 100644 --- a/portal/src/ReactApp.tsx +++ b/portal/src/ReactApp.tsx @@ -21,7 +21,6 @@ import { Context, } from "@oursky/react-messageformat"; import { ApolloProvider } from "@apollo/client"; -import authgear from "@authgear/web"; import { Helmet, HelmetProvider } from "react-helmet-async"; import AppRoot from "./AppRoot"; import MESSAGES from "./locale-data/en.json"; @@ -37,7 +36,9 @@ import { import { loadTheme, ILinkProps } from "@fluentui/react"; import ExternalLink from "./ExternalLink"; import Link from "./Link"; -import Authenticated from "./graphql/portal/Authenticated"; +import Authenticated, { + configureAuthgear, +} from "./graphql/portal/Authenticated"; import InternalRedirect from "./InternalRedirect"; import { LoadingContextProvider } from "./hook/loading"; import { ErrorContextProvider } from "./hook/error"; @@ -116,11 +117,6 @@ async function initApp(systemConfig: SystemConfig) { } loadTheme(systemConfig.themes.main); - await authgear.configure({ - sessionType: "cookie", - clientID: systemConfig.authgearClientID, - endpoint: systemConfig.authgearEndpoint, - }); } // ReactAppRoutes defines the routes. @@ -340,6 +336,10 @@ const ReactApp: React.VFC = function ReactApp() { loadSystemConfig() .then(async (cfg) => { await initApp(cfg); + await configureAuthgear({ + clientID: cfg.authgearClientID, + endpoint: cfg.authgearEndpoint, + }); setSystemConfig(cfg); }) .catch((err) => { diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index 68f8e4e6b0..8c4438ff77 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -114,4 +114,19 @@ export function useLogout(): () => Promise { return logout; } +export interface ConfigureAuthgearOptions { + clientID: string; + endpoint: string; +} + +export async function configureAuthgear( + options: ConfigureAuthgearOptions +): Promise { + await authgear.configure({ + sessionType: "cookie", + clientID: options.clientID, + endpoint: options.endpoint, + }); +} + export default Authenticated; From 09f5d4f06fd314dae81fbba1cf17dce9c1be7a0b Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 13:26:29 +0800 Subject: [PATCH 07/20] Introduce AuthenticatedContext that support both sessionType --- portal/src/OAuthRedirect.tsx | 8 +- portal/src/ReactApp.tsx | 25 ++-- portal/src/graphql/portal/Authenticated.tsx | 129 +++++++++++++++++++- 3 files changed, 142 insertions(+), 20 deletions(-) diff --git a/portal/src/OAuthRedirect.tsx b/portal/src/OAuthRedirect.tsx index 9bc1425396..baa590eb3e 100644 --- a/portal/src/OAuthRedirect.tsx +++ b/portal/src/OAuthRedirect.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; -import authgear from "@authgear/web"; import { useNavigate } from "react-router-dom"; +import { useFinishAuthentication } from "./graphql/portal/Authenticated"; function decodeOAuthState(oauthState: string): Record { // eslint-disable-next-line @typescript-eslint/no-deprecated @@ -13,10 +13,10 @@ function isString(value: unknown): value is string { const OAuthRedirect: React.VFC = function OAuthRedirect() { const navigate = useNavigate(); + const finishAuthentication = useFinishAuthentication(); useEffect(() => { - authgear - .finishAuthentication() + finishAuthentication() .then((result) => { const state = result.state ? decodeOAuthState(result.state) : null; let navigateToPath = "/"; @@ -37,7 +37,7 @@ const OAuthRedirect: React.VFC = function OAuthRedirect() { .catch((err) => { console.error(err); }); - }, [navigate]); + }, [navigate, finishAuthentication]); return null; }; diff --git a/portal/src/ReactApp.tsx b/portal/src/ReactApp.tsx index d854d6ee8f..3b4d7329e7 100644 --- a/portal/src/ReactApp.tsx +++ b/portal/src/ReactApp.tsx @@ -38,6 +38,7 @@ import ExternalLink from "./ExternalLink"; import Link from "./Link"; import Authenticated, { configureAuthgear, + AuthenticatedContextProvider, } from "./graphql/portal/Authenticated"; import InternalRedirect from "./InternalRedirect"; import { LoadingContextProvider } from "./hook/loading"; @@ -379,17 +380,19 @@ const ReactApp: React.VFC = function ReactApp() { - - - - - - + + + + + + + + diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index 8c4438ff77..5319816d36 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -1,5 +1,19 @@ -import React, { useEffect, useCallback } from "react"; -import authgear, { PromptOption } from "@authgear/web"; +import React, { + useEffect, + useCallback, + useMemo, + useState, + useContext, + createContext, +} from "react"; +import authgear, { + PromptOption, + WebContainer, + SessionStateChangeReason, + SessionState, + AuthenticateResult, + ConfigureOptions, +} from "@authgear/web"; import { useNavigate } from "react-router-dom"; import ShowError from "../../ShowError"; import ShowLoading from "../../ShowLoading"; @@ -7,6 +21,24 @@ import { useViewerQuery } from "./query/viewerQuery"; import { InternalRedirectState } from "../../InternalRedirect"; import { useReset } from "../../gtm_v2"; +const SESSION_TYPE: NonNullable = "cookie"; + +interface AuthenticatedContextValue { + loading: boolean; + error: unknown; + authenticated: boolean; + refetch: () => Promise; +} + +const DEFAULT_VALUE: AuthenticatedContextValue = { + loading: true, + error: null, + authenticated: false, + refetch: async () => {}, +}; + +const AuthenticatedContext = createContext(DEFAULT_VALUE); + interface ShowQueryResultProps { isAuthenticated: boolean; children?: React.ReactElement; @@ -58,7 +90,8 @@ interface Props { const Authenticated: React.VFC = function Authenticated( ownProps: Props ) { - const { loading, error, viewer, refetch } = useViewerQuery(); + const { loading, error, authenticated, refetch } = + useContext(AuthenticatedContext); if (loading) { return ; @@ -68,7 +101,7 @@ const Authenticated: React.VFC = function Authenticated( return ; } - return ; + return ; }; // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters @@ -114,6 +147,24 @@ export function useLogout(): () => Promise { return logout; } +// useFinishAuthentication was introduced to avoid a possible race condition. +// The ultimate source of truth to determine whether the user has authenticated or not is by checking viewer != null. +// Therefore, when we finish authentication, we always refetch the query. +// +// Not doing this will result in a situation where +// 1. authgear.accessToken != null (In the end-user's point of view, he just authenticated, and being redirected back to the portal) +// 2. { loading = false, viewer = null } (because refetch() DOES NOT set loading to true immediately) +// 3. Then Authenticated will redirect the end-user to authenticate again, which is buggy. +export function useFinishAuthentication(): () => Promise { + const { refetch } = useContext(AuthenticatedContext); + const finishAuthentication = useCallback(async () => { + const result = await authgear.finishAuthentication(); + await refetch(); + return result; + }, [refetch]); + return finishAuthentication; +} + export interface ConfigureAuthgearOptions { clientID: string; endpoint: string; @@ -122,11 +173,79 @@ export interface ConfigureAuthgearOptions { export async function configureAuthgear( options: ConfigureAuthgearOptions ): Promise { + // eslint-disable-next-line no-console -- Output the session type to console for easier debugging. + console.log("authgear: sessionType =", SESSION_TYPE); await authgear.configure({ - sessionType: "cookie", + sessionType: SESSION_TYPE, clientID: options.clientID, endpoint: options.endpoint, }); } +export interface AuthenticatedContextProviderProps { + children?: React.ReactElement; +} + +export function AuthenticatedContextProvider( + props: AuthenticatedContextProviderProps +): React.ReactElement | null { + const [sessionState, setSessionState] = useState(authgear.sessionState); + const { viewer, loading, error, refetch } = useViewerQuery(); + + const delegate = useMemo(() => { + return { + onSessionStateChange: ( + container: WebContainer, + _reason: SessionStateChangeReason + ) => { + setSessionState(container.sessionState); + refetch(); + }, + }; + }, [refetch]); + + // Set delegate + useEffect(() => { + authgear.delegate = delegate; + }, [delegate]); + + const value = useMemo(() => { + let authenticated = false; + switch (SESSION_TYPE) { + case "cookie": + // FIXME(authgear-sdk): Update to the version that includes https://github.com/authgear/authgear-sdk-js/pull/336 + // When switching from refresh_token to cookie, with the fix in https://github.com/authgear/authgear-sdk-js/pull/336, + // authgear SDK will not load refresh token, then thus authgear.fetch will not include + // Authorization header. + // + // We just need to check if we can actually fetch viewer. + authenticated = viewer != null; + break; + case "refresh_token": + // When switching from cookie to refresh_token, the cookie may left in the browser. + // So we have to check if authgear SDK does have a stored refresh_token. + // This checking is reflected by authgear.sessionState. + authenticated = + sessionState === SessionState.Authenticated && viewer != null; + break; + + // So now, switching between cookie and refresh_token is possible and work seamlessly. + // Of course, the switch implies the end-user has to authenticate again. + } + + return { + loading, + error, + authenticated, + refetch, + }; + }, [sessionState, loading, error, viewer, refetch]); + + return ( + + {props.children} + + ); +} + export default Authenticated; From 5cb5e56650d88aefc3f331d1107fda0e83c0110c Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 16:18:38 +0800 Subject: [PATCH 08/20] Only mount SessionInfoMiddleware in routes that need session Previously it was mounted at root. But /api/system-config.json and /api/subscription/webhook/stripe does not need session. --- pkg/portal/routes.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/portal/routes.go b/pkg/portal/routes.go index 07a0790265..3bb8d3fe10 100644 --- a/pkg/portal/routes.go +++ b/pkg/portal/routes.go @@ -31,7 +31,6 @@ func NewRouter(p *deps.RootProvider) http.Handler { p.Middleware(newPanicMiddleware), p.Middleware(newBodyLimitMiddleware), p.Middleware(newSentryMiddleware), - p.Middleware(newSessionInfoMiddleware), ) systemConfigJSONChain := httproute.Chain( rootChain, @@ -40,6 +39,7 @@ func NewRouter(p *deps.RootProvider) http.Handler { ) graphqlChain := httproute.Chain( rootChain, + p.Middleware(newSessionInfoMiddleware), securityMiddleware, httproute.MiddlewareFunc(httputil.NoStore), httputil.CheckContentType([]string{ @@ -49,6 +49,7 @@ func NewRouter(p *deps.RootProvider) http.Handler { ) adminAPIChain := httproute.Chain( rootChain, + p.Middleware(newSessionInfoMiddleware), // Middlewares that write headers are intentionally left out for this chain. // It is because the handler of this chain is a httputil.ReverseProxy. // We assume the proxied response has correct headers. From 7ca56e99acbca2ef69a8ec8df78c43814af5637c Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 18:17:02 +0800 Subject: [PATCH 09/20] Add envvar AUTHGEAR_WEB_SDK_SESSION_TYPE --- .env.example | 1 + pkg/portal/config/authgear.go | 2 ++ pkg/portal/model/system_config.go | 33 +++++++++++++++-------------- pkg/portal/service/system_config.go | 33 +++++++++++++++-------------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index c4e7468594..7173356b77 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ TRUST_PROXY=true AUTHGEAR_APP_ID=accounts AUTHGEAR_CLIENT_ID=portal AUTHGEAR_ENDPOINT=http://accounts.portal.localhost:3100 +AUTHGEAR_WEB_SDK_SESSION_TYPE=refresh_token # Use a pool size of 2 to make potential deadlock visible. # 1 connection is dedicated for LISTEN config source change. diff --git a/pkg/portal/config/authgear.go b/pkg/portal/config/authgear.go index 60a99d7c2f..0e138d691f 100644 --- a/pkg/portal/config/authgear.go +++ b/pkg/portal/config/authgear.go @@ -4,4 +4,6 @@ type AuthgearConfig struct { ClientID string `envconfig:"CLIENT_ID"` Endpoint string `envconfig:"ENDPOINT"` AppID string `envconfig:"APP_ID"` + + WebSDKSessionType string `envconfig:"WEB_SDK_SESSION_TYPE" default:"cookie"` } diff --git a/pkg/portal/model/system_config.go b/pkg/portal/model/system_config.go index 24c48528a6..112bc7b648 100644 --- a/pkg/portal/model/system_config.go +++ b/pkg/portal/model/system_config.go @@ -5,20 +5,21 @@ import ( ) type SystemConfig struct { - AuthgearClientID string `json:"authgearClientID"` - AuthgearEndpoint string `json:"authgearEndpoint"` - SentryDSN string `json:"sentryDSN,omitempty"` - AppHostSuffix string `json:"appHostSuffix"` - AvailableLanguages []string `json:"availableLanguages"` - BuiltinLanguages []string `json:"builtinLanguages"` - Themes interface{} `json:"themes,omitempty"` - Translations interface{} `json:"translations,omitempty"` - SearchEnabled bool `json:"searchEnabled"` - AuditLogEnabled bool `json:"auditLogEnabled"` - AnalyticEnabled bool `json:"analyticEnabled"` - AnalyticEpoch *timeutil.Date `json:"analyticEpoch,omitempty"` - GitCommitHash string `json:"gitCommitHash,omitempty"` - GTMContainerID string `json:"gtmContainerID,omitempty"` - UIImplementation string `json:"uiImplementation,omitempty"` - UISettingsImplementation string `json:"uiSettingsImplementation,omitempty"` + AuthgearClientID string `json:"authgearClientID"` + AuthgearEndpoint string `json:"authgearEndpoint"` + AuthgearWebSDKSessionType string `json:"authgearWebSDKSessionType"` + SentryDSN string `json:"sentryDSN,omitempty"` + AppHostSuffix string `json:"appHostSuffix"` + AvailableLanguages []string `json:"availableLanguages"` + BuiltinLanguages []string `json:"builtinLanguages"` + Themes interface{} `json:"themes,omitempty"` + Translations interface{} `json:"translations,omitempty"` + SearchEnabled bool `json:"searchEnabled"` + AuditLogEnabled bool `json:"auditLogEnabled"` + AnalyticEnabled bool `json:"analyticEnabled"` + AnalyticEpoch *timeutil.Date `json:"analyticEpoch,omitempty"` + GitCommitHash string `json:"gitCommitHash,omitempty"` + GTMContainerID string `json:"gtmContainerID,omitempty"` + UIImplementation string `json:"uiImplementation,omitempty"` + UISettingsImplementation string `json:"uiSettingsImplementation,omitempty"` } diff --git a/pkg/portal/service/system_config.go b/pkg/portal/service/system_config.go index 536e294041..1f45b2dbf2 100644 --- a/pkg/portal/service/system_config.go +++ b/pkg/portal/service/system_config.go @@ -49,22 +49,23 @@ func (p *SystemConfigProvider) SystemConfig() (*model.SystemConfig, error) { } return &model.SystemConfig{ - AuthgearClientID: p.AuthgearConfig.ClientID, - AuthgearEndpoint: p.AuthgearConfig.Endpoint, - SentryDSN: p.FrontendSentryConfig.DSN, - AppHostSuffix: p.AppConfig.HostSuffix, - AvailableLanguages: intl.AvailableLanguages, - BuiltinLanguages: intl.BuiltinLanguages, - Themes: themes, - Translations: translations, - SearchEnabled: p.SearchConfig.Enabled, - AuditLogEnabled: p.AuditLogConfig.Enabled, - AnalyticEnabled: p.AnalyticConfig.Enabled, - AnalyticEpoch: analyticEpoch, - GitCommitHash: strings.TrimPrefix(version.Version, "git-"), - GTMContainerID: p.GTMConfig.ContainerID, - UIImplementation: string(p.GlobalUIImplementation), - UISettingsImplementation: string(p.GlobalUISettingsImplementation), + AuthgearClientID: p.AuthgearConfig.ClientID, + AuthgearEndpoint: p.AuthgearConfig.Endpoint, + AuthgearWebSDKSessionType: p.AuthgearConfig.WebSDKSessionType, + SentryDSN: p.FrontendSentryConfig.DSN, + AppHostSuffix: p.AppConfig.HostSuffix, + AvailableLanguages: intl.AvailableLanguages, + BuiltinLanguages: intl.BuiltinLanguages, + Themes: themes, + Translations: translations, + SearchEnabled: p.SearchConfig.Enabled, + AuditLogEnabled: p.AuditLogConfig.Enabled, + AnalyticEnabled: p.AnalyticConfig.Enabled, + AnalyticEpoch: analyticEpoch, + GitCommitHash: strings.TrimPrefix(version.Version, "git-"), + GTMContainerID: p.GTMConfig.ContainerID, + UIImplementation: string(p.GlobalUIImplementation), + UISettingsImplementation: string(p.GlobalUISettingsImplementation), }, nil } From 190e1d4a1dc967653d9d2d54997b160f1a1feccb Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 18:58:37 +0800 Subject: [PATCH 10/20] Use sessionType from system config --- portal/src/ReactApp.tsx | 1 + portal/src/graphql/portal/Authenticated.tsx | 9 ++++----- portal/src/system-config.ts | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/portal/src/ReactApp.tsx b/portal/src/ReactApp.tsx index 3b4d7329e7..489325b8a2 100644 --- a/portal/src/ReactApp.tsx +++ b/portal/src/ReactApp.tsx @@ -340,6 +340,7 @@ const ReactApp: React.VFC = function ReactApp() { await configureAuthgear({ clientID: cfg.authgearClientID, endpoint: cfg.authgearEndpoint, + sessionType: cfg.authgearWebSDKSessionType, }); setSystemConfig(cfg); }) diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index 5319816d36..95f5069b19 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -21,8 +21,6 @@ import { useViewerQuery } from "./query/viewerQuery"; import { InternalRedirectState } from "../../InternalRedirect"; import { useReset } from "../../gtm_v2"; -const SESSION_TYPE: NonNullable = "cookie"; - interface AuthenticatedContextValue { loading: boolean; error: unknown; @@ -168,15 +166,16 @@ export function useFinishAuthentication(): () => Promise { export interface ConfigureAuthgearOptions { clientID: string; endpoint: string; + sessionType: NonNullable; } export async function configureAuthgear( options: ConfigureAuthgearOptions ): Promise { // eslint-disable-next-line no-console -- Output the session type to console for easier debugging. - console.log("authgear: sessionType =", SESSION_TYPE); + console.log("authgear: sessionType =", options.sessionType); await authgear.configure({ - sessionType: SESSION_TYPE, + sessionType: options.sessionType, clientID: options.clientID, endpoint: options.endpoint, }); @@ -211,7 +210,7 @@ export function AuthenticatedContextProvider( const value = useMemo(() => { let authenticated = false; - switch (SESSION_TYPE) { + switch (authgear.sessionType) { case "cookie": // FIXME(authgear-sdk): Update to the version that includes https://github.com/authgear/authgear-sdk-js/pull/336 // When switching from refresh_token to cookie, with the fix in https://github.com/authgear/authgear-sdk-js/pull/336, diff --git a/portal/src/system-config.ts b/portal/src/system-config.ts index c1583df37c..0dee2c0f8a 100644 --- a/portal/src/system-config.ts +++ b/portal/src/system-config.ts @@ -5,6 +5,7 @@ import { DEFAULT_TEMPLATE_LOCALE } from "./resources"; export interface SystemConfig { authgearClientID: string; authgearEndpoint: string; + authgearWebSDKSessionType: "cookie" | "refresh_token"; sentryDSN: string; appHostSuffix: string; availableLanguages: string[]; @@ -246,6 +247,7 @@ export function instantiateSystemConfig( return { authgearClientID: config.authgearClientID ?? "", authgearEndpoint: config.authgearEndpoint ?? "", + authgearWebSDKSessionType: config.authgearWebSDKSessionType ?? "cookie", sentryDSN: config.sentryDSN ?? "", appHostSuffix: config.appHostSuffix ?? "", availableLanguages: config.availableLanguages ?? [DEFAULT_TEMPLATE_LOCALE], From 5892e4bb42603891f6c93dced6c069acd5a46cb0 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Fri, 10 Jan 2025 19:16:22 +0800 Subject: [PATCH 11/20] Support AUTHGEAR_WEB_SDK_SESSION_TYPE in SessionInfoMiddleware --- .vettedpositions | 3 +- pkg/portal/session/deps.go | 16 ++ pkg/portal/session/middleware_session_info.go | 153 +++++++++++++++++- pkg/portal/wire.go | 1 + pkg/portal/wire_gen.go | 37 +++-- 5 files changed, 188 insertions(+), 22 deletions(-) diff --git a/.vettedpositions b/.vettedpositions index 7e8e4a8efc..ea4d752b9b 100644 --- a/.vettedpositions +++ b/.vettedpositions @@ -297,7 +297,8 @@ /pkg/lib/session/test/context.go:85:35: requestcontext /pkg/lib/workflow/intl_middleware.go:16:41: requestcontext /pkg/portal/csp_middleware.go:16:39: requestcontext -/pkg/portal/session/middleware_session_info.go:19:37: requestcontext +/pkg/portal/session/middleware_session_info.go:54:9: requestcontext +/pkg/portal/session/middleware_session_info.go:175:36: requestcontext /pkg/portal/session/middleware_session_required.go:12:38: requestcontext /pkg/portal/transport/admin_api_handler.go:54:45: requestcontext /pkg/portal/transport/admin_api_handler.go:61:44: requestcontext diff --git a/pkg/portal/session/deps.go b/pkg/portal/session/deps.go index 32cb11cdb1..3d3d83e3e6 100644 --- a/pkg/portal/session/deps.go +++ b/pkg/portal/session/deps.go @@ -1,10 +1,26 @@ package session import ( + "net/http" + "time" + "github.com/google/wire" + + "github.com/authgear/authgear-server/pkg/util/httputil" ) +type HTTPClient struct { + *http.Client +} + +func NewHTTPClient() HTTPClient { + return HTTPClient{ + httputil.NewExternalClient(5 * time.Second), + } +} + var DependencySet = wire.NewSet( + NewHTTPClient, wire.Struct(new(SessionInfoMiddleware), "*"), wire.Struct(new(SessionRequiredMiddleware), "*"), ) diff --git a/pkg/portal/session/middleware_session_info.go b/pkg/portal/session/middleware_session_info.go index a9aae17582..f6a03583ce 100644 --- a/pkg/portal/session/middleware_session_info.go +++ b/pkg/portal/session/middleware_session_info.go @@ -1,22 +1,159 @@ package session import ( + "context" + "fmt" "net/http" + "net/url" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/patrickmn/go-cache" "github.com/authgear/authgear-server/pkg/api/model" + "github.com/authgear/authgear-server/pkg/lib/oauthrelyingparty/oauthrelyingpartyutil" + portalconfig "github.com/authgear/authgear-server/pkg/portal/config" + "github.com/authgear/authgear-server/pkg/util/clock" + "github.com/authgear/authgear-server/pkg/util/duration" ) -// nolint:golint -type SessionInfoMiddleware struct{} +var simpleCache = cache.New(5*time.Minute, 10*time.Minute) + +const cacheKeyOpenIDConfiguration = "openid-configuration" +const cacheKeyJWKs = "jwks" + +type jwtClock struct { + Clock clock.Clock +} + +func (c jwtClock) Now() time.Time { + return c.Clock.NowUTC() +} + +type SessionInfoMiddleware struct { + AuthgearConfig *portalconfig.AuthgearConfig + HTTPClient HTTPClient + Clock clock.Clock +} func (m *SessionInfoMiddleware) Handle(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sessionInfo, err := model.NewSessionInfoFromHeaders(r.Header) - if err != nil { - panic(err) + switch m.AuthgearConfig.WebSDKSessionType { + case "refresh_token": + m.handleAuthorizationHeader(next, w, r) + case "cookie": + fallthrough + default: + m.handleCookie(next, w, r) } - - r = r.WithContext(WithSessionInfo(r.Context(), sessionInfo)) - next.ServeHTTP(w, r) }) } + +func (m *SessionInfoMiddleware) handleAuthorizationHeader(next http.Handler, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + jwkSet, err := m.getJWKs(ctx) + if err != nil { + panic(err) + } + + var sessionInfo *model.SessionInfo + authorization := r.Header.Get("Authorization") + if authorization == "" { + // keep sessionInfo as nil. It means no session. + } else { + sessionInfo = m.jwtToSessionInfo(jwkSet, r.Header) + } + + r = r.WithContext(WithSessionInfo(ctx, sessionInfo)) + next.ServeHTTP(w, r) +} + +func (m *SessionInfoMiddleware) jwtToSessionInfo(jwkSet jwk.Set, header http.Header) (sessionInfo *model.SessionInfo) { + // Initialize to zero value. + // Zero value means invalid session. + sessionInfo = &model.SessionInfo{} + + token, err := jwt.ParseHeader(header, "Authorization", + jwt.WithVerify(true), + jwt.WithKeySet(jwkSet), + jwt.WithClock(jwtClock{m.Clock}), + jwt.WithAcceptableSkew(duration.ClockSkew), + ) + if err != nil { + return + } + + sessionInfo.UserID = token.Subject() + + anonymousIface, ok := token.Get(string(model.ClaimUserIsAnonymous)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserIsAnonymous)) + } + sessionInfo.UserAnonymous = anonymousIface.(bool) + + isVerifiedIface, ok := token.Get(string(model.ClaimUserIsVerified)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserIsVerified)) + } + sessionInfo.UserVerified = isVerifiedIface.(bool) + + canReauthenticate, ok := token.Get(string(model.ClaimUserCanReauthenticate)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimUserCanReauthenticate)) + } + sessionInfo.UserCanReauthenticate = canReauthenticate.(bool) + + // FIXME(auth_time): Include auth_time in JWT access token. + // FIXME(amr): Include amr in JWT access token. + + rolesIface, ok := token.Get(string(model.ClaimAuthgearRoles)) + if !ok { + panic(fmt.Errorf("expected claim to be present: %v", model.ClaimAuthgearRoles)) + } + rolesSlice := rolesIface.([]interface{}) + for _, roleIface := range rolesSlice { + role := roleIface.(string) + sessionInfo.EffectiveRoles = append(sessionInfo.EffectiveRoles, role) + } + + sessionInfo.IsValid = true + return +} + +func (m *SessionInfoMiddleware) getJWKs(ctx context.Context) (jwk.Set, error) { + jwkIface, ok := simpleCache.Get(cacheKeyJWKs) + if ok { + return jwkIface.(jwk.Set), nil + } + + endpoint, err := url.JoinPath(m.AuthgearConfig.Endpoint, "/.well-known/openid-configuration") + if err != nil { + return nil, err + } + + oidcDiscoveryDocument, err := oauthrelyingpartyutil.FetchOIDCDiscoveryDocument(ctx, m.HTTPClient.Client, endpoint) + if err != nil { + return nil, err + } + simpleCache.Set(cacheKeyOpenIDConfiguration, oidcDiscoveryDocument, 0) + + jwkSet, err := oidcDiscoveryDocument.FetchJWKs(ctx, m.HTTPClient.Client) + if err != nil { + return nil, err + } + simpleCache.Set(cacheKeyJWKs, jwkSet, 0) + + return jwkSet, nil +} + +func (m *SessionInfoMiddleware) handleCookie(next http.Handler, w http.ResponseWriter, r *http.Request) { + sessionInfo, err := model.NewSessionInfoFromHeaders(r.Header) + if err != nil { + panic(err) + } + + r = r.WithContext(WithSessionInfo(r.Context(), sessionInfo)) + next.ServeHTTP(w, r) +} diff --git a/pkg/portal/wire.go b/pkg/portal/wire.go index f40002c4eb..31e57c31ea 100644 --- a/pkg/portal/wire.go +++ b/pkg/portal/wire.go @@ -40,6 +40,7 @@ func newSentryMiddleware(p *deps.RequestProvider) httproute.Middleware { func newSessionInfoMiddleware(p *deps.RequestProvider) httproute.Middleware { panic(wire.Build( + DependencySet, session.DependencySet, wire.Bind(new(httproute.Middleware), new(*session.SessionInfoMiddleware)), )) diff --git a/pkg/portal/wire_gen.go b/pkg/portal/wire_gen.go index 04613fe8ab..64b2833ae6 100644 --- a/pkg/portal/wire_gen.go +++ b/pkg/portal/wire_gen.go @@ -72,10 +72,22 @@ func newSentryMiddleware(p *deps.RequestProvider) httproute.Middleware { } func newSessionInfoMiddleware(p *deps.RequestProvider) httproute.Middleware { - sessionInfoMiddleware := &session.SessionInfoMiddleware{} + rootProvider := p.RootProvider + authgearConfig := rootProvider.AuthgearConfig + httpClient := session.NewHTTPClient() + clock := _wireSystemClockValue + sessionInfoMiddleware := &session.SessionInfoMiddleware{ + AuthgearConfig: authgearConfig, + HTTPClient: httpClient, + Clock: clock, + } return sessionInfoMiddleware } +var ( + _wireSystemClockValue = clock.NewSystemClock() +) + func newSessionRequiredMiddleware(p *deps.RequestProvider) httproute.Middleware { sessionRequiredMiddleware := &session.SessionRequiredMiddleware{} return sessionRequiredMiddleware @@ -96,9 +108,9 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { adminAPIConfig := rootProvider.AdminAPIConfig controller := rootProvider.ConfigSourceController configSource := deps.ProvideConfigSource(controller) - clock := _wireSystemClockValue + clockClock := _wireSystemClockValue adder := &authz.Adder{ - Clock: clock, + Clock: clockClock, } appHostSuffixes := environmentConfig.AppHostSuffixes appConfig := rootProvider.AppConfig @@ -122,7 +134,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { sqlBuilder := globaldb.NewSQLBuilder(globalDatabaseCredentialsEnvironmentConfig) sqlExecutor := globaldb.NewSQLExecutor(handle) domainService := &service.DomainService{ - Clock: clock, + Clock: clockClock, DomainConfig: configService, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, @@ -179,7 +191,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Resolver: resolver, } collaboratorService := &service.CollaboratorService{ - Clock: clock, + Clock: clockClock, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, HTTPClient: httpClient, @@ -213,12 +225,12 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { AppBaseResources: appBaseResources, Tutorials: tutorialService, DenoClient: denoClientImpl, - Clock: clock, + Clock: clockClock, EnvironmentConfig: environmentConfig, DomainService: domainService, } store := &plan.Store{ - Clock: clock, + Clock: clockClock, SQLBuilder: sqlBuilder, SQLExecutor: sqlExecutor, } @@ -251,7 +263,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Resources: manager, AppResMgrFactory: managerFactory, Plan: planService, - Clock: clock, + Clock: clockClock, AppSecretVisitTokenStore: appSecretVisitTokenStoreImpl, AppTesterTokenStore: testerStore, SAMLEnvironmentConfig: samlEnvironmentConfig, @@ -275,7 +287,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { chartService := &analytic.ChartService{ Database: readHandle, AuditStore: auditDBReadStore, - Clock: clock, + Clock: clockClock, AnalyticConfig: analyticConfig, } stripeConfig := rootProvider.StripeConfig @@ -288,7 +300,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { Plans: planService, GlobalRedisHandle: globalredisHandle, Cache: stripeCache, - Clock: clock, + Clock: clockClock, StripeConfig: stripeConfig, Endpoints: endpointsProvider, } @@ -303,7 +315,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { ConfigSourceStore: configsourceStore, PlanStore: store, UsageStore: globalDBStore, - Clock: clock, + Clock: clockClock, AppConfig: appConfig, } usageService := &service.UsageService{ @@ -324,7 +336,7 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { GlobalSQLExecutor: sqlExecutor, GlobalDatabase: handle, AuditDatabase: writeHandle, - Clock: clock, + Clock: clockClock, LoggerFactory: logFactory, } onboardService := &service.OnboardService{ @@ -364,7 +376,6 @@ func newGraphQLHandler(p *deps.RequestProvider) http.Handler { } var ( - _wireSystemClockValue = clock.NewSystemClock() _wireDefaultLanguageTagValue = template.DefaultLanguageTag(intl.BuiltinBaseLanguage) _wireSupportedLanguageTagsValue = template.SupportedLanguageTags([]string{intl.BuiltinBaseLanguage}) ) From c2de9e498537e9b0cfd184074bfad3b5bf1a3edf Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 11:51:38 +0800 Subject: [PATCH 12/20] Re-organize id token and at+jwt generation code --- pkg/lib/oauth/oidc/id_token.go | 28 +++++++++++++--------------- pkg/lib/oauth/token_encoding.go | 16 +++++++++++----- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/pkg/lib/oauth/oidc/id_token.go b/pkg/lib/oauth/oidc/id_token.go index 81e79d3cbd..6b81fde058 100644 --- a/pkg/lib/oauth/oidc/id_token.go +++ b/pkg/lib/oauth/oidc/id_token.go @@ -106,12 +106,6 @@ func (ti *IDTokenIssuer) Iss() string { return ti.BaseURL.Origin().String() } -func (ti *IDTokenIssuer) updateTimeClaims(token jwt.Token) { - now := ti.Clock.NowUTC() - _ = token.Set(jwt.IssuedAtKey, now.Unix()) - _ = token.Set(jwt.ExpirationKey, now.Add(IDTokenValidDuration).Unix()) -} - func (ti *IDTokenIssuer) sign(token jwt.Token) (string, error) { jwk, _ := ti.Secrets.Set.Key(0) signed, err := jwtutil.Sign(token, jwa.RS256, jwk) @@ -135,29 +129,33 @@ func (ti *IDTokenIssuer) IssueIDToken(ctx context.Context, opts IssueIDTokenOpti info := opts.AuthenticationInfo - // Populate issuer. + // iss _ = claims.Set(jwt.IssuerKey, ti.Iss()) + // aud + _ = claims.Set(jwt.AudienceKey, opts.ClientID) + now := ti.Clock.NowUTC() + // iat + _ = claims.Set(jwt.IssuedAtKey, now.Unix()) + // exp + _ = claims.Set(jwt.ExpirationKey, now.Add(IDTokenValidDuration).Unix()) err := ti.PopulateUserClaimsInIDToken(ctx, claims, info.UserID, opts.ClientLike) if err != nil { return "", err } - // Populate client specific claims - _ = claims.Set(jwt.AudienceKey, opts.ClientID) - - // Populate Time specific claims - ti.updateTimeClaims(claims) - - // Populate session specific claims + // auth_time + _ = claims.Set(string(model.ClaimAuthTime), info.AuthenticatedAt.Unix()) if sid := opts.SID; sid != "" { + // sid _ = claims.Set(string(model.ClaimSID), sid) } - _ = claims.Set(string(model.ClaimAuthTime), info.AuthenticatedAt.Unix()) if amr := info.AMR; len(amr) > 0 { + // amr _ = claims.Set(string(model.ClaimAMR), amr) } if dshash := opts.DeviceSecretHash; dshash != "" { + // ds_hash _ = claims.Set(string(model.ClaimDeviceSecretHash), dshash) } diff --git a/pkg/lib/oauth/token_encoding.go b/pkg/lib/oauth/token_encoding.go index bf33fc0ff7..d4be4f9e49 100644 --- a/pkg/lib/oauth/token_encoding.go +++ b/pkg/lib/oauth/token_encoding.go @@ -57,21 +57,27 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *con claims := jwt.New() - err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, userID, clientLike) - if err != nil { - return "", err - } - + // iss _ = claims.Set(jwt.IssuerKey, e.IDTokenIssuer.Iss()) + // aud _ = claims.Set(jwt.AudienceKey, e.BaseURL.Origin().String()) + // iat _ = claims.Set(jwt.IssuedAtKey, grant.CreatedAt.Unix()) + // exp _ = claims.Set(jwt.ExpirationKey, grant.ExpireAt.Unix()) + // client_id _ = claims.Set("client_id", client.ClientID) + // Do not put raw token in JWT access token; JWT payload is not specified // to be confidential. Put token hash to allow looking up access grant from // verified JWT. _ = claims.Set(jwt.JwtIDKey, grant.TokenHash) + err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, userID, clientLike) + if err != nil { + return "", err + } + forMutation, forBackup, err := jwtutil.PrepareForMutations(claims) if err != nil { return "", err From 80e0e340d114ef4ffaada2f6a60fd190fafd8f97 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 12:20:23 +0800 Subject: [PATCH 13/20] Move sid handling to oauth --- pkg/auth/handler/saml/logout.go | 4 +- pkg/auth/handler/webapp/logout.go | 4 +- pkg/lib/oauth/handler/handler_authz.go | 2 +- pkg/lib/oauth/handler/handler_authz_test.go | 2 +- pkg/lib/oauth/handler/handler_token.go | 20 ++++---- pkg/lib/oauth/handler/handler_token_test.go | 3 +- pkg/lib/oauth/oidc/id_token.go | 51 +------------------ pkg/lib/oauth/oidc/id_token_test.go | 46 ++--------------- pkg/lib/oauth/oidc/ui.go | 2 +- pkg/lib/oauth/sid.go | 56 +++++++++++++++++++++ pkg/lib/oauth/sid_test.go | 51 +++++++++++++++++++ pkg/lib/saml/service.go | 3 +- 12 files changed, 130 insertions(+), 114 deletions(-) create mode 100644 pkg/lib/oauth/sid.go create mode 100644 pkg/lib/oauth/sid_test.go diff --git a/pkg/auth/handler/saml/logout.go b/pkg/auth/handler/saml/logout.go index 9367be11f3..0f4ce4b644 100644 --- a/pkg/auth/handler/saml/logout.go +++ b/pkg/auth/handler/saml/logout.go @@ -9,7 +9,7 @@ import ( "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/infra/db/appdb" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" + "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/saml" "github.com/authgear/authgear-server/pkg/lib/saml/samlbinding" "github.com/authgear/authgear-server/pkg/lib/saml/samlprotocol" @@ -405,7 +405,7 @@ func (h *LogoutHandler) invalidateSession( affectedServiceProviderIDs setutil.Set[string], err error, ) { - _, sessionID, ok := oidc.DecodeSID(sid) + _, sessionID, ok := oauth.DecodeSID(sid) if ok { s, err := h.SessionManager.Get(ctx, sessionID) if err != nil { diff --git a/pkg/auth/handler/webapp/logout.go b/pkg/auth/handler/webapp/logout.go index 110bd88b8a..1ad108ed40 100644 --- a/pkg/auth/handler/webapp/logout.go +++ b/pkg/auth/handler/webapp/logout.go @@ -8,7 +8,7 @@ import ( "github.com/authgear/authgear-server/pkg/auth/webapp" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/infra/db/appdb" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" + "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/saml/samlslosession" "github.com/authgear/authgear-server/pkg/lib/session" "github.com/authgear/authgear-server/pkg/lib/uiparam" @@ -101,7 +101,7 @@ func (h *LogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(pendingLogoutServiceProviderIDs.Keys()) > 0 { sloSessionEntry := &samlslosession.SAMLSLOSessionEntry{ PendingLogoutServiceProviderIDs: pendingLogoutServiceProviderIDs.Keys(), - SID: oidc.EncodeSID(sess), + SID: oauth.EncodeSID(sess), UserID: sess.GetAuthenticationInfo().UserID, PostLogoutRedirectURI: redirectURI, } diff --git a/pkg/lib/oauth/handler/handler_authz.go b/pkg/lib/oauth/handler/handler_authz.go index ee1a983a1f..aef6132fff 100644 --- a/pkg/lib/oauth/handler/handler_authz.go +++ b/pkg/lib/oauth/handler/handler_authz.go @@ -536,7 +536,7 @@ func (h *AuthorizationHandler) doHandlePreAuthenticatedURL( if sid, ok = sidInt.(string); !ok { return nil, protocol.NewError("invalid_request", "sid is not a string in id_token_hint") } - _, sessionID, ok := oidc.DecodeSID(sid) + _, sessionID, ok := oauth.DecodeSID(sid) if !ok { return nil, protocol.NewError("invalid_request", "invalid sid format id_token_hint") } diff --git a/pkg/lib/oauth/handler/handler_authz_test.go b/pkg/lib/oauth/handler/handler_authz_test.go index 4607cc2f44..c1815b635f 100644 --- a/pkg/lib/oauth/handler/handler_authz_test.go +++ b/pkg/lib/oauth/handler/handler_authz_test.go @@ -486,7 +486,7 @@ func TestAuthorizationHandler(t *testing.T) { testOfflineGrant := &oauth.OfflineGrant{ ID: testOfflineGrantID, } - testSID := oidc.EncodeSID(testOfflineGrant) + testSID := oauth.EncodeSID(testOfflineGrant) // nolint:gosec testPreAuthenticatedURLToken := "TEST_PRE_AUTHENTICATED_URL_TOKEN" diff --git a/pkg/lib/oauth/handler/handler_token.go b/pkg/lib/oauth/handler/handler_token.go index e023e3c76d..2638a745d9 100644 --- a/pkg/lib/oauth/handler/handler_token.go +++ b/pkg/lib/oauth/handler/handler_token.go @@ -687,7 +687,7 @@ func (h *TokenHandler) resolveIDTokenSession(ctx context.Context, idToken jwt.To return nil, false, nil } - typ, sessionID, ok := oidc.DecodeSID(sid) + typ, sessionID, ok := oauth.DecodeSID(sid) if !ok { return nil, false, nil } @@ -835,7 +835,7 @@ func (h *TokenHandler) handlePreAuthenticatedURLToken( // Issue new id_token which associated to the new device_secret newIDToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), // scopes are used for specifying which fields should be included in the ID token // those fields may include personal identifiable information @@ -981,7 +981,7 @@ func (h *TokenHandler) handleAnonymousRequest( if slice.ContainsString(scopes, "openid") { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, @@ -1238,7 +1238,7 @@ func (h *TokenHandler) handleBiometricAuthenticate( if slice.ContainsString(scopes, "openid") { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, @@ -1428,7 +1428,7 @@ func (h *TokenHandler) handleIDToken( } idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(s), + SID: oauth.EncodeSID(s), AuthenticationInfo: s.GetAuthenticationInfo(), // scopes are used for specifying which fields should be included in the ID token // those fields may include personal identifiable information @@ -1535,7 +1535,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( // Reauth // Update auth_time, app2app device key and device_secret of the offline grant if possible. if sid := code.IDTokenHintSID; sid != "" { - if typ, sessionID, ok := oidc.DecodeSID(sid); ok && typ == session.TypeOfflineGrant { + if typ, sessionID, ok := oauth.DecodeSID(sid); ok && typ == session.TypeOfflineGrant { offlineGrant, err := h.OfflineGrantService.GetOfflineGrant(ctx, sessionID) if err == nil { // Update auth_time @@ -1627,7 +1627,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( } } - sid = oidc.EncodeSID(offlineGrant) + sid = oauth.EncodeSID(offlineGrant) accessTokenSessionID = offlineGrant.ID accessTokenSessionKind = oauth.GrantSessionKindOffline refreshTokenHash = tokenHash @@ -1651,7 +1651,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( } } else if code.IDTokenHintSID != "" { sid = code.IDTokenHintSID - if typ, sessionID, ok := oidc.DecodeSID(sid); ok { + if typ, sessionID, ok := oauth.DecodeSID(sid); ok { accessTokenSessionID = sessionID switch typ { case session.TypeOfflineGrant: @@ -1681,7 +1681,7 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( if err != nil { return nil, err } - sid = oidc.EncodeSID(offlineGrant) + sid = oauth.EncodeSID(offlineGrant) accessTokenSessionID = offlineGrant.ID accessTokenSessionKind = oauth.GrantSessionKindOffline } @@ -1754,7 +1754,7 @@ func (h *TokenHandler) issueTokensForRefreshToken( if issueIDToken { idToken, err := h.IDTokenIssuer.IssueIDToken(ctx, oidc.IssueIDTokenOptions{ ClientID: client.ClientID, - SID: oidc.EncodeSID(offlineGrantSession.OfflineGrant), + SID: oauth.EncodeSID(offlineGrantSession.OfflineGrant), AuthenticationInfo: offlineGrantSession.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, authz.Scopes), DeviceSecretHash: offlineGrant.DeviceSecretHash, diff --git a/pkg/lib/oauth/handler/handler_token_test.go b/pkg/lib/oauth/handler/handler_token_test.go index ced43f69ad..c293c848d3 100644 --- a/pkg/lib/oauth/handler/handler_token_test.go +++ b/pkg/lib/oauth/handler/handler_token_test.go @@ -16,7 +16,6 @@ import ( "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/oauth" "github.com/authgear/authgear-server/pkg/lib/oauth/handler" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" "github.com/authgear/authgear-server/pkg/lib/oauth/protocol" "github.com/authgear/authgear-server/pkg/lib/session" "github.com/authgear/authgear-server/pkg/lib/session/access" @@ -149,7 +148,7 @@ func TestTokenHandler(t *testing.T) { offlineGrantService.EXPECT().GetOfflineGrant(gomock.Any(), testOfflineGrantID). AnyTimes(). Return(testOfflineGrant, nil) - sid := oidc.EncodeSID(testOfflineGrant) + sid := oauth.EncodeSID(testOfflineGrant) mockIdToken := jwt.New() _ = mockIdToken.Set("iss", origin) _ = mockIdToken.Set("sid", sid) diff --git a/pkg/lib/oauth/oidc/id_token.go b/pkg/lib/oauth/oidc/id_token.go index 6b81fde058..9d04a55cd2 100644 --- a/pkg/lib/oauth/oidc/id_token.go +++ b/pkg/lib/oauth/oidc/id_token.go @@ -2,11 +2,8 @@ package oidc import ( "context" - "encoding/base64" "fmt" "net/url" - "strings" - "unicode/utf8" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" @@ -52,52 +49,6 @@ type IDTokenIssuer struct { // It can be short, since id_token_hint should accept expired ID tokens. const IDTokenValidDuration = duration.Short -type SessionLike interface { - SessionID() string - SessionType() session.Type -} - -func EncodeSID(s SessionLike) string { - return EncodeSIDByRawValues(s.SessionType(), s.SessionID()) -} - -func EncodeSIDByRawValues(sessionType session.Type, sessionID string) string { - raw := fmt.Sprintf("%s:%s", sessionType, sessionID) - return base64.RawURLEncoding.EncodeToString([]byte(raw)) -} - -func DecodeSID(sid string) (typ session.Type, sessionID string, ok bool) { - bytes, err := base64.RawURLEncoding.DecodeString(sid) - if err != nil { - return - } - - if !utf8.Valid(bytes) { - return - } - str := string(bytes) - - parts := strings.Split(str, ":") - if len(parts) != 2 { - return - } - - typStr := parts[0] - sessionID = parts[1] - switch typStr { - case string(session.TypeIdentityProvider): - typ = session.TypeIdentityProvider - case string(session.TypeOfflineGrant): - typ = session.TypeOfflineGrant - } - if typ == "" { - return - } - - ok = true - return -} - func (ti *IDTokenIssuer) GetPublicKeySet() (jwk.Set, error) { return jwk.PublicSetOf(ti.Secrets.Set) } @@ -330,7 +281,7 @@ func (r *IDTokenHintResolver) ResolveIDTokenHint(ctx context.Context, client *co return } - typ, sessionID, ok := DecodeSID(sid) + typ, sessionID, ok := oauth.DecodeSID(sid) if !ok { return } diff --git a/pkg/lib/oauth/oidc/id_token_test.go b/pkg/lib/oauth/oidc/id_token_test.go index 2489899a45..039d862e06 100644 --- a/pkg/lib/oauth/oidc/id_token_test.go +++ b/pkg/lib/oauth/oidc/id_token_test.go @@ -49,47 +49,7 @@ eZDnqWNf7mYPdP5mO5iTtMw= -----END PRIVATE KEY----- ` -type FakeSession struct { - ID string - Type session.Type -} - -func (s *FakeSession) SessionID() string { - return s.ID -} - -func (s *FakeSession) SessionType() session.Type { - return s.Type -} - -func TestSID(t *testing.T) { - Convey("EncodeSID and DecodeSID", t, func() { - s := &FakeSession{ - ID: "a", - Type: session.TypeIdentityProvider, - } - typ, sessionID, ok := DecodeSID(EncodeSID(s)) - So(typ, ShouldEqual, session.TypeIdentityProvider) - So(sessionID, ShouldEqual, "a") - So(ok, ShouldBeTrue) - - s = &FakeSession{ - ID: "b", - Type: session.TypeOfflineGrant, - } - typ, sessionID, ok = DecodeSID(EncodeSID(s)) - So(typ, ShouldEqual, session.TypeOfflineGrant) - So(sessionID, ShouldEqual, "b") - So(ok, ShouldBeTrue) - - s = &FakeSession{ - ID: "c", - Type: "nonsense", - } - _, _, ok = DecodeSID(EncodeSID(s)) - So(ok, ShouldBeFalse) - }) - +func TestIDTokenIssuer(t *testing.T) { Convey("IssueIDToken and VerifyIDToken", t, func() { ctrl := gomock.NewController(t) @@ -163,7 +123,7 @@ func TestSID(t *testing.T) { ctx := context.Background() idToken, err := issuer.IssueIDToken(ctx, IssueIDTokenOptions{ ClientID: "client-id", - SID: EncodeSID(offlineGrant), + SID: oauth.EncodeSID(offlineGrant), AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), ClientLike: oauth.ClientClientLike(client, scopes), Nonce: "nonce-1", @@ -194,7 +154,7 @@ func TestSID(t *testing.T) { // Session claims encodedSessionID, _ := token.Get(string(model.ClaimSID)) - _, sessionID, _ := DecodeSID(encodedSessionID.(string)) + _, sessionID, _ := oauth.DecodeSID(encodedSessionID.(string)) So(sessionID, ShouldEqual, offlineGrant.ID) ds_hash, _ := token.Get(string(model.ClaimDeviceSecretHash)) diff --git a/pkg/lib/oauth/oidc/ui.go b/pkg/lib/oauth/oidc/ui.go index 98604e80d3..a44b02b0a6 100644 --- a/pkg/lib/oauth/oidc/ui.go +++ b/pkg/lib/oauth/oidc/ui.go @@ -180,7 +180,7 @@ func (r *UIInfoResolver) ResolveForAuthorizationEndpoint( var idTokenHintSID string if sidSession != nil { - idTokenHintSID = EncodeSID(sidSession) + idTokenHintSID = oauth.EncodeSID(sidSession) } var userIDHint string diff --git a/pkg/lib/oauth/sid.go b/pkg/lib/oauth/sid.go new file mode 100644 index 0000000000..55bb9348b5 --- /dev/null +++ b/pkg/lib/oauth/sid.go @@ -0,0 +1,56 @@ +package oauth + +import ( + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + +type SessionLike interface { + SessionID() string + SessionType() session.Type +} + +func EncodeSID(s SessionLike) string { + return EncodeSIDByRawValues(s.SessionType(), s.SessionID()) +} + +func EncodeSIDByRawValues(sessionType session.Type, sessionID string) string { + raw := fmt.Sprintf("%s:%s", sessionType, sessionID) + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func DecodeSID(sid string) (typ session.Type, sessionID string, ok bool) { + bytes, err := base64.RawURLEncoding.DecodeString(sid) + if err != nil { + return + } + + if !utf8.Valid(bytes) { + return + } + str := string(bytes) + + parts := strings.Split(str, ":") + if len(parts) != 2 { + return + } + + typStr := parts[0] + sessionID = parts[1] + switch typStr { + case string(session.TypeIdentityProvider): + typ = session.TypeIdentityProvider + case string(session.TypeOfflineGrant): + typ = session.TypeOfflineGrant + } + if typ == "" { + return + } + + ok = true + return +} diff --git a/pkg/lib/oauth/sid_test.go b/pkg/lib/oauth/sid_test.go new file mode 100644 index 0000000000..7800809f30 --- /dev/null +++ b/pkg/lib/oauth/sid_test.go @@ -0,0 +1,51 @@ +package oauth + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + +type FakeSession struct { + ID string + Type session.Type +} + +func (s *FakeSession) SessionID() string { + return s.ID +} + +func (s *FakeSession) SessionType() session.Type { + return s.Type +} + +func TestSID(t *testing.T) { + Convey("EncodeSID and DecodeSID", t, func() { + s := &FakeSession{ + ID: "a", + Type: session.TypeIdentityProvider, + } + typ, sessionID, ok := DecodeSID(EncodeSID(s)) + So(typ, ShouldEqual, session.TypeIdentityProvider) + So(sessionID, ShouldEqual, "a") + So(ok, ShouldBeTrue) + + s = &FakeSession{ + ID: "b", + Type: session.TypeOfflineGrant, + } + typ, sessionID, ok = DecodeSID(EncodeSID(s)) + So(typ, ShouldEqual, session.TypeOfflineGrant) + So(sessionID, ShouldEqual, "b") + So(ok, ShouldBeTrue) + + s = &FakeSession{ + ID: "c", + Type: "nonsense", + } + _, _, ok = DecodeSID(EncodeSID(s)) + So(ok, ShouldBeFalse) + }) +} diff --git a/pkg/lib/saml/service.go b/pkg/lib/saml/service.go index 93114f467e..a196e7c9b4 100644 --- a/pkg/lib/saml/service.go +++ b/pkg/lib/saml/service.go @@ -17,7 +17,6 @@ import ( "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/oauth" - "github.com/authgear/authgear-server/pkg/lib/oauth/oidc" "github.com/authgear/authgear-server/pkg/lib/saml/samlprotocol" "github.com/authgear/authgear-server/pkg/lib/saml/samlslosession" "github.com/authgear/authgear-server/pkg/lib/session" @@ -293,7 +292,7 @@ func (s *Service) IssueLoginSuccessResponse( return nil, samlprotocol.ErrServiceProviderNotFound } authenticatedUserId := authInfo.UserID - sid := oidc.EncodeSIDByRawValues( + sid := oauth.EncodeSIDByRawValues( session.Type(authInfo.AuthenticatedBySessionType), authInfo.AuthenticatedBySessionID, ) From 10da0481f138d781e1d02530d0a696a7f60965aa Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 12:59:48 +0800 Subject: [PATCH 14/20] Make IssueAccessGrant and EncodeAccessToken take options --- pkg/admin/facade/oauth.go | 23 +++--- pkg/lib/oauth/grant.go | 28 +++++++ pkg/lib/oauth/grant_access_service.go | 42 ++++++---- .../oauth/handler/handler_anonymous_user.go | 22 +++++- pkg/lib/oauth/handler/handler_token.go | 76 +++++++++++++------ .../oauth/handler/handler_token_mock_test.go | 16 ++-- pkg/lib/oauth/handler/handler_token_test.go | 2 +- .../service_app_initiated_sso_to_web.go | 16 ++-- pkg/lib/oauth/handler/service_token.go | 10 +-- pkg/lib/oauth/oidc/id_token_mock_test.go | 52 ------------- pkg/lib/oauth/pre_authenticated_url_token.go | 9 +-- pkg/lib/oauth/token_encoding.go | 29 ++++--- pkg/lib/oauth/token_encoding_test.go | 14 +++- 13 files changed, 188 insertions(+), 151 deletions(-) diff --git a/pkg/admin/facade/oauth.go b/pkg/admin/facade/oauth.go index 6352210e26..cbd089206a 100644 --- a/pkg/admin/facade/oauth.go +++ b/pkg/admin/facade/oauth.go @@ -31,13 +31,7 @@ type OAuthTokenService interface { ) (offlineGrant *oauth.OfflineGrant, tokenHash string, err error) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error } @@ -108,13 +102,14 @@ func (f *OAuthFacade) CreateSession(ctx context.Context, clientID string, userID err = f.Tokens.IssueAccessGrant( ctx, - client, - scopes, - authz.ID, - authz.UserID, - offlineGrant.ID, - oauth.GrantSessionKindOffline, - tokenHash, + oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + }, resp, ) if err != nil { diff --git a/pkg/lib/oauth/grant.go b/pkg/lib/oauth/grant.go index c798df49d2..426214721b 100644 --- a/pkg/lib/oauth/grant.go +++ b/pkg/lib/oauth/grant.go @@ -1,8 +1,36 @@ package oauth +import ( + "fmt" + + "github.com/authgear/authgear-server/pkg/lib/session" +) + type GrantSessionKind string const ( GrantSessionKindOffline GrantSessionKind = "offline_grant" GrantSessionKindSession GrantSessionKind = "idp_session" ) + +func (k GrantSessionKind) SessionType() session.Type { + switch k { + case GrantSessionKindSession: + return session.TypeIdentityProvider + case GrantSessionKindOffline: + return session.TypeOfflineGrant + default: + panic(fmt.Errorf("unknown session kind: %v\n", k)) + } +} + +func GrantSessionKindFromSessionType(typ session.Type) GrantSessionKind { + switch typ { + case session.TypeIdentityProvider: + return GrantSessionKindSession + case session.TypeOfflineGrant: + return GrantSessionKindOffline + default: + panic(fmt.Errorf("unknown session type: %v", typ)) + } +} diff --git a/pkg/lib/oauth/grant_access_service.go b/pkg/lib/oauth/grant_access_service.go index 62f7a06f25..7dc91cfe60 100644 --- a/pkg/lib/oauth/grant_access_service.go +++ b/pkg/lib/oauth/grant_access_service.go @@ -3,6 +3,7 @@ package oauth import ( "context" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/clock" ) @@ -15,6 +16,15 @@ type AccessGrantService struct { Clock clock.Clock } +type IssueAccessGrantOptions struct { + ClientConfig *config.OAuthClientConfig + Scopes []string + AuthorizationID string + AuthenticationInfo authenticationinfo.T + SessionLike SessionLike + RefreshTokenHash string +} + type IssueAccessGrantResult struct { Token string TokenType string @@ -23,35 +33,35 @@ type IssueAccessGrantResult struct { func (s *AccessGrantService) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind GrantSessionKind, - refreshTokenHash string, + options IssueAccessGrantOptions, ) (*IssueAccessGrantResult, error) { token := GenerateToken() now := s.Clock.NowUTC() accessGrant := &AccessGrant{ AppID: string(s.AppID), - AuthorizationID: authzID, - SessionID: sessionID, - SessionKind: sessionKind, + AuthorizationID: options.AuthorizationID, + SessionID: options.SessionLike.SessionID(), + SessionKind: GrantSessionKindFromSessionType(options.SessionLike.SessionType()), CreatedAt: now, - ExpireAt: now.Add(client.AccessTokenLifetime.Duration()), - Scopes: scopes, + ExpireAt: now.Add(options.ClientConfig.AccessTokenLifetime.Duration()), + Scopes: options.Scopes, TokenHash: HashToken(token), - RefreshTokenHash: refreshTokenHash, + RefreshTokenHash: options.RefreshTokenHash, } err := s.AccessGrants.CreateAccessGrant(ctx, accessGrant) if err != nil { return nil, err } - clientLike := ClientClientLike(client, scopes) - at, err := s.AccessTokenIssuer.EncodeAccessToken(ctx, client, clientLike, accessGrant, userID, token) + clientLike := ClientClientLike(options.ClientConfig, options.Scopes) + at, err := s.AccessTokenIssuer.EncodeAccessToken(ctx, EncodeAccessTokenOptions{ + OriginalToken: token, + ClientConfig: options.ClientConfig, + ClientLike: clientLike, + AccessGrant: accessGrant, + AuthenticationInfo: options.AuthenticationInfo, + }) if err != nil { return nil, err } @@ -59,7 +69,7 @@ func (s *AccessGrantService) IssueAccessGrant( result := &IssueAccessGrantResult{ Token: at, TokenType: "Bearer", - ExpiresIn: int(client.AccessTokenLifetime), + ExpiresIn: int(options.ClientConfig.AccessTokenLifetime), } return result, nil diff --git a/pkg/lib/oauth/handler/handler_anonymous_user.go b/pkg/lib/oauth/handler/handler_anonymous_user.go index ee6d1d68fe..29cf1a5c57 100644 --- a/pkg/lib/oauth/handler/handler_anonymous_user.go +++ b/pkg/lib/oauth/handler/handler_anonymous_user.go @@ -174,8 +174,15 @@ func (h *AnonymousUserHandler) signupAnonymousUserWithRefreshTokenSessionType( } resp := protocol.TokenResponse{} - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - grant.ID, oauth.GrantSessionKindOffline, refreshTokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: grant.GetAuthenticationInfo(), + SessionLike: grant, + RefreshTokenHash: refreshTokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { return nil, err } @@ -222,8 +229,15 @@ func (h *AnonymousUserHandler) signupAnonymousUserWithRefreshTokenSessionType( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: info, + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { return nil, err } diff --git a/pkg/lib/oauth/handler/handler_token.go b/pkg/lib/oauth/handler/handler_token.go index 2638a745d9..7b24faf757 100644 --- a/pkg/lib/oauth/handler/handler_token.go +++ b/pkg/lib/oauth/handler/handler_token.go @@ -95,7 +95,7 @@ type IDTokenIssuer interface { } type AccessTokenIssuer interface { - EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *oauth.ClientLike, grant *oauth.AccessGrant, userID string, token string) (string, error) + EncodeAccessToken(ctx context.Context, options oauth.EncodeAccessTokenOptions) (string, error) } type EventService interface { @@ -161,13 +161,7 @@ type TokenHandlerTokenService interface { ParseRefreshToken(ctx context.Context, token string) (authz *oauth.Authorization, offlineGrant *oauth.OfflineGrant, tokenHash string, err error) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error IssueOfflineGrant( @@ -205,6 +199,19 @@ type PreAuthenticatedURLTokenService interface { ) (string, error) } +type SimpleSessionLike struct { + ID string + GrantSessionKind oauth.GrantSessionKind +} + +func (s SimpleSessionLike) SessionID() string { + return s.ID +} + +func (s SimpleSessionLike) SessionType() session.Type { + return s.GrantSessionKind.SessionType() +} + type TokenHandler struct { AppID config.AppID AppDomains config.AppDomains @@ -971,8 +978,15 @@ func (h *TokenHandler) handleAnonymousRequest( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err @@ -1228,8 +1242,15 @@ func (h *TokenHandler) handleBiometricAuthenticate( return nil, err } - err = h.TokenService.IssueAccessGrant(ctx, client, scopes, authz.ID, authz.UserID, - offlineGrant.ID, oauth.GrantSessionKindOffline, tokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: tokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err @@ -1690,15 +1711,20 @@ func (h *TokenHandler) doIssueTokensForAuthorizationCode( return nil, protocol.NewError("invalid_request", "cannot issue access token") } + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: code.AuthorizationRequest.Scope(), + AuthorizationID: authz.ID, + AuthenticationInfo: info, + SessionLike: SimpleSessionLike{ + ID: accessTokenSessionID, + GrantSessionKind: accessTokenSessionKind, + }, + RefreshTokenHash: refreshTokenHash, + } err := h.TokenService.IssueAccessGrant( ctx, - client, - code.AuthorizationRequest.Scope(), - authz.ID, - authz.UserID, - accessTokenSessionID, - accessTokenSessionKind, - refreshTokenHash, + issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) @@ -1765,9 +1791,15 @@ func (h *TokenHandler) issueTokensForRefreshToken( resp.IDToken(idToken) } - err = h.TokenService.IssueAccessGrant(ctx, client, offlineGrantSession.Scopes, - authz.ID, authz.UserID, offlineGrantSession.SessionID(), - oauth.GrantSessionKindOffline, offlineGrantSession.TokenHash, resp) + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: offlineGrantSession.Scopes, + AuthorizationID: authz.ID, + AuthenticationInfo: offlineGrantSession.GetAuthenticationInfo(), + SessionLike: offlineGrantSession, + RefreshTokenHash: offlineGrantSession.TokenHash, + } + err = h.TokenService.IssueAccessGrant(ctx, issueAccessGrantOptions, resp) if err != nil { err = h.translateAccessTokenError(err) return nil, err diff --git a/pkg/lib/oauth/handler/handler_token_mock_test.go b/pkg/lib/oauth/handler/handler_token_mock_test.go index 88a9ca0450..6e17e6efdb 100644 --- a/pkg/lib/oauth/handler/handler_token_mock_test.go +++ b/pkg/lib/oauth/handler/handler_token_mock_test.go @@ -116,18 +116,18 @@ func (m *MockAccessTokenIssuer) EXPECT() *MockAccessTokenIssuerMockRecorder { } // EncodeAccessToken mocks base method. -func (m *MockAccessTokenIssuer) EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *oauth.ClientLike, grant *oauth.AccessGrant, userID, token string) (string, error) { +func (m *MockAccessTokenIssuer) EncodeAccessToken(ctx context.Context, options oauth.EncodeAccessTokenOptions) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "EncodeAccessToken", ctx, client, clientLike, grant, userID, token) + ret := m.ctrl.Call(m, "EncodeAccessToken", ctx, options) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // EncodeAccessToken indicates an expected call of EncodeAccessToken. -func (mr *MockAccessTokenIssuerMockRecorder) EncodeAccessToken(ctx, client, clientLike, grant, userID, token interface{}) *gomock.Call { +func (mr *MockAccessTokenIssuerMockRecorder) EncodeAccessToken(ctx, options interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncodeAccessToken", reflect.TypeOf((*MockAccessTokenIssuer)(nil).EncodeAccessToken), ctx, client, clientLike, grant, userID, token) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EncodeAccessToken", reflect.TypeOf((*MockAccessTokenIssuer)(nil).EncodeAccessToken), ctx, options) } // MockEventService is a mock of EventService interface. @@ -641,17 +641,17 @@ func (m *MockTokenHandlerTokenService) EXPECT() *MockTokenHandlerTokenServiceMoc } // IssueAccessGrant mocks base method. -func (m *MockTokenHandlerTokenService) IssueAccessGrant(ctx context.Context, client *config.OAuthClientConfig, scopes []string, authzID, userID, sessionID string, sessionKind oauth.GrantSessionKind, refreshTokenHash string, resp protocol.TokenResponse) error { +func (m *MockTokenHandlerTokenService) IssueAccessGrant(ctx context.Context, options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IssueAccessGrant", ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp) + ret := m.ctrl.Call(m, "IssueAccessGrant", ctx, options, resp) ret0, _ := ret[0].(error) return ret0 } // IssueAccessGrant indicates an expected call of IssueAccessGrant. -func (mr *MockTokenHandlerTokenServiceMockRecorder) IssueAccessGrant(ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp interface{}) *gomock.Call { +func (mr *MockTokenHandlerTokenServiceMockRecorder) IssueAccessGrant(ctx, options, resp interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssueAccessGrant", reflect.TypeOf((*MockTokenHandlerTokenService)(nil).IssueAccessGrant), ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, resp) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssueAccessGrant", reflect.TypeOf((*MockTokenHandlerTokenService)(nil).IssueAccessGrant), ctx, options, resp) } // IssueDeviceSecret mocks base method. diff --git a/pkg/lib/oauth/handler/handler_token_test.go b/pkg/lib/oauth/handler/handler_token_test.go index c293c848d3..8ec88b8527 100644 --- a/pkg/lib/oauth/handler/handler_token_test.go +++ b/pkg/lib/oauth/handler/handler_token_test.go @@ -102,7 +102,7 @@ func TestTokenHandler(t *testing.T) { } tokenService.EXPECT().ParseRefreshToken(gomock.Any(), "asdf").Return(&oauth.Authorization{}, offlineGrant, refreshTokenHash, nil) idTokenIssuer.EXPECT().IssueIDToken(gomock.Any(), gomock.Any()).Return("id-token", nil) - tokenService.EXPECT().IssueAccessGrant(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + tokenService.EXPECT().IssueAccessGrant(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) event := access.NewEvent(clock.NowUTC(), "1.2.3.4", "UA") offlineGrantService.EXPECT().AccessOfflineGrant(gomock.Any(), "offline-grant-id", &event, offlineGrant.ExpireAtForResolvedSession).Return(offlineGrant, nil) offlineGrants.EXPECT().UpdateOfflineGrantDeviceInfo(gomock.Any(), "offline-grant-id", gomock.Any(), offlineGrant.ExpireAtForResolvedSession).Return(offlineGrant, nil) diff --git a/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go b/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go index 87e4bf5857..88406ee815 100644 --- a/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go +++ b/pkg/lib/oauth/handler/service_app_initiated_sso_to_web.go @@ -95,15 +95,17 @@ func (s *PreAuthenticatedURLTokenServiceImpl) ExchangeForAccessToken( } offlineGrant = newOfflineGrant + issueAccessGrantOptions := oauth.IssueAccessGrantOptions{ + ClientConfig: client, + Scopes: tokenModel.Scopes, + AuthorizationID: tokenModel.AuthorizationID, + AuthenticationInfo: offlineGrant.GetAuthenticationInfo(), + SessionLike: offlineGrant, + RefreshTokenHash: newRefreshTokenResult.TokenHash, + } result, err := s.AccessGrantService.IssueAccessGrant( ctx, - client, - tokenModel.Scopes, - tokenModel.AuthorizationID, - offlineGrant.GetUserID(), - offlineGrant.ID, - oauth.GrantSessionKindOffline, - newRefreshTokenResult.TokenHash, + issueAccessGrantOptions, ) if err != nil { diff --git a/pkg/lib/oauth/handler/service_token.go b/pkg/lib/oauth/handler/service_token.go index c0f2c02761..de103f3d94 100644 --- a/pkg/lib/oauth/handler/service_token.go +++ b/pkg/lib/oauth/handler/service_token.go @@ -170,17 +170,11 @@ func (s *TokenService) IssueRefreshTokenForOfflineGrant( func (s *TokenService) IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind oauth.GrantSessionKind, - refreshTokenHash string, + options oauth.IssueAccessGrantOptions, resp protocol.TokenResponse, ) error { result, err := s.AccessGrantService.IssueAccessGrant( - ctx, client, scopes, authzID, userID, sessionID, sessionKind, refreshTokenHash, + ctx, options, ) if err != nil { return err diff --git a/pkg/lib/oauth/oidc/id_token_mock_test.go b/pkg/lib/oauth/oidc/id_token_mock_test.go index 84d03ec673..a4ed1e913e 100644 --- a/pkg/lib/oauth/oidc/id_token_mock_test.go +++ b/pkg/lib/oauth/oidc/id_token_mock_test.go @@ -11,7 +11,6 @@ import ( model "github.com/authgear/authgear-server/pkg/api/model" oauth "github.com/authgear/authgear-server/pkg/lib/oauth" - session "github.com/authgear/authgear-server/pkg/lib/session" idpsession "github.com/authgear/authgear-server/pkg/lib/session/idpsession" accesscontrol "github.com/authgear/authgear-server/pkg/util/accesscontrol" gomock "github.com/golang/mock/gomock" @@ -131,57 +130,6 @@ func (mr *MockBaseURLProviderMockRecorder) Origin() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Origin", reflect.TypeOf((*MockBaseURLProvider)(nil).Origin)) } -// MockSessionLike is a mock of SessionLike interface. -type MockSessionLike struct { - ctrl *gomock.Controller - recorder *MockSessionLikeMockRecorder -} - -// MockSessionLikeMockRecorder is the mock recorder for MockSessionLike. -type MockSessionLikeMockRecorder struct { - mock *MockSessionLike -} - -// NewMockSessionLike creates a new mock instance. -func NewMockSessionLike(ctrl *gomock.Controller) *MockSessionLike { - mock := &MockSessionLike{ctrl: ctrl} - mock.recorder = &MockSessionLikeMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSessionLike) EXPECT() *MockSessionLikeMockRecorder { - return m.recorder -} - -// SessionID mocks base method. -func (m *MockSessionLike) SessionID() string { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionID") - ret0, _ := ret[0].(string) - return ret0 -} - -// SessionID indicates an expected call of SessionID. -func (mr *MockSessionLikeMockRecorder) SessionID() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionID", reflect.TypeOf((*MockSessionLike)(nil).SessionID)) -} - -// SessionType mocks base method. -func (m *MockSessionLike) SessionType() session.Type { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionType") - ret0, _ := ret[0].(session.Type) - return ret0 -} - -// SessionType indicates an expected call of SessionType. -func (mr *MockSessionLikeMockRecorder) SessionType() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionType", reflect.TypeOf((*MockSessionLike)(nil).SessionType)) -} - // MockIDTokenHintResolverIssuer is a mock of IDTokenHintResolverIssuer interface. type MockIDTokenHintResolverIssuer struct { ctrl *gomock.Controller diff --git a/pkg/lib/oauth/pre_authenticated_url_token.go b/pkg/lib/oauth/pre_authenticated_url_token.go index 2895112a17..c5a44dfb3a 100644 --- a/pkg/lib/oauth/pre_authenticated_url_token.go +++ b/pkg/lib/oauth/pre_authenticated_url_token.go @@ -4,7 +4,6 @@ import ( "context" "time" - "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/duration" ) @@ -27,13 +26,7 @@ type PreAuthenticatedURLToken struct { type PreAuthenticatedURLTokenAccessGrantService interface { IssueAccessGrant( ctx context.Context, - client *config.OAuthClientConfig, - scopes []string, - authzID string, - userID string, - sessionID string, - sessionKind GrantSessionKind, - refreshTokenHash string, + options IssueAccessGrantOptions, ) (*IssueAccessGrantResult, error) } diff --git a/pkg/lib/oauth/token_encoding.go b/pkg/lib/oauth/token_encoding.go index d4be4f9e49..899d5d3f6a 100644 --- a/pkg/lib/oauth/token_encoding.go +++ b/pkg/lib/oauth/token_encoding.go @@ -16,6 +16,7 @@ import ( "github.com/authgear/authgear-server/pkg/api/event" "github.com/authgear/authgear-server/pkg/api/event/blocking" "github.com/authgear/authgear-server/pkg/api/model" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/authn/identity" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/util/clock" @@ -50,9 +51,17 @@ type AccessTokenEncoding struct { Identities AccessTokenEncodingIdentityService } -func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *config.OAuthClientConfig, clientLike *ClientLike, grant *AccessGrant, userID string, token string) (string, error) { - if !client.IssueJWTAccessToken { - return token, nil +type EncodeAccessTokenOptions struct { + OriginalToken string + ClientConfig *config.OAuthClientConfig + ClientLike *ClientLike + AccessGrant *AccessGrant + AuthenticationInfo authenticationinfo.T +} + +func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, options EncodeAccessTokenOptions) (string, error) { + if !options.ClientConfig.IssueJWTAccessToken { + return options.OriginalToken, nil } claims := jwt.New() @@ -62,18 +71,18 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *con // aud _ = claims.Set(jwt.AudienceKey, e.BaseURL.Origin().String()) // iat - _ = claims.Set(jwt.IssuedAtKey, grant.CreatedAt.Unix()) + _ = claims.Set(jwt.IssuedAtKey, options.AccessGrant.CreatedAt.Unix()) // exp - _ = claims.Set(jwt.ExpirationKey, grant.ExpireAt.Unix()) + _ = claims.Set(jwt.ExpirationKey, options.AccessGrant.ExpireAt.Unix()) // client_id - _ = claims.Set("client_id", client.ClientID) + _ = claims.Set("client_id", options.ClientConfig.ClientID) // Do not put raw token in JWT access token; JWT payload is not specified // to be confidential. Put token hash to allow looking up access grant from // verified JWT. - _ = claims.Set(jwt.JwtIDKey, grant.TokenHash) + _ = claims.Set(jwt.JwtIDKey, options.AccessGrant.TokenHash) - err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, userID, clientLike) + err := e.IDTokenIssuer.PopulateUserClaimsInIDToken(ctx, claims, options.AuthenticationInfo.UserID, options.ClientLike) if err != nil { return "", err } @@ -83,7 +92,7 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *con return "", err } - identities, err := e.Identities.ListIdentitiesThatHaveStandardAttributes(ctx, userID) + identities, err := e.Identities.ListIdentitiesThatHaveStandardAttributes(ctx, options.AuthenticationInfo.UserID) if err != nil { return "", err } @@ -96,7 +105,7 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, client *con eventPayload := &blocking.OIDCJWTPreCreateBlockingEventPayload{ UserRef: model.UserRef{ Meta: model.Meta{ - ID: userID, + ID: options.AuthenticationInfo.UserID, }, }, Identities: identityModels, diff --git a/pkg/lib/oauth/token_encoding_test.go b/pkg/lib/oauth/token_encoding_test.go index 653a2e1160..e685c499c6 100644 --- a/pkg/lib/oauth/token_encoding_test.go +++ b/pkg/lib/oauth/token_encoding_test.go @@ -10,6 +10,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" . "github.com/smartystreets/goconvey/convey" + "github.com/authgear/authgear-server/pkg/lib/authn/authenticationinfo" "github.com/authgear/authgear-server/pkg/lib/config" "github.com/authgear/authgear-server/pkg/lib/endpoints" "github.com/authgear/authgear-server/pkg/util/clock" @@ -98,7 +99,18 @@ func TestAccessToken(t *testing.T) { mockIdentityService.EXPECT().ListIdentitiesThatHaveStandardAttributes(gomock.Any(), "user-id").Return(nil, nil) ctx := context.Background() - accessToken, err := encoding.EncodeAccessToken(ctx, client, clientLike, accessGrant, "user-id", "token") + options := EncodeAccessTokenOptions{ + OriginalToken: "token", + ClientConfig: client, + ClientLike: clientLike, + AccessGrant: accessGrant, + AuthenticationInfo: authenticationinfo.T{ + UserID: "user-id", + // AMR + // AuthenticatedAt + }, + } + accessToken, err := encoding.EncodeAccessToken(ctx, options) So(err, ShouldBeNil) _, _, err = encoding.DecodeAccessToken(accessToken) From 022960e6d78695958bd5c1fb20b3d776eca6289f Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 13:03:43 +0800 Subject: [PATCH 15/20] Include auth_time and amr in at+jwt --- pkg/lib/oauth/token_encoding.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/lib/oauth/token_encoding.go b/pkg/lib/oauth/token_encoding.go index 899d5d3f6a..0832da3325 100644 --- a/pkg/lib/oauth/token_encoding.go +++ b/pkg/lib/oauth/token_encoding.go @@ -77,6 +77,14 @@ func (e *AccessTokenEncoding) EncodeAccessToken(ctx context.Context, options Enc // client_id _ = claims.Set("client_id", options.ClientConfig.ClientID) + // auth_time + _ = claims.Set(string(model.ClaimAuthTime), options.AuthenticationInfo.AuthenticatedAt.Unix()) + + // amr + if amr := options.AuthenticationInfo.AMR; len(amr) > 0 { + _ = claims.Set(string(model.ClaimAMR), amr) + } + // Do not put raw token in JWT access token; JWT payload is not specified // to be confidential. Put token hash to allow looking up access grant from // verified JWT. From 655cdb95b09834dbe43e82944aec57c8fbf89bbb Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 13:09:10 +0800 Subject: [PATCH 16/20] Read amr in at+jwt --- pkg/portal/session/middleware_session_info.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/portal/session/middleware_session_info.go b/pkg/portal/session/middleware_session_info.go index f6a03583ce..795bffa92a 100644 --- a/pkg/portal/session/middleware_session_info.go +++ b/pkg/portal/session/middleware_session_info.go @@ -106,7 +106,15 @@ func (m *SessionInfoMiddleware) jwtToSessionInfo(jwkSet jwk.Set, header http.Hea sessionInfo.UserCanReauthenticate = canReauthenticate.(bool) // FIXME(auth_time): Include auth_time in JWT access token. - // FIXME(amr): Include amr in JWT access token. + + // amr is newly added to at+jwt, so it may not be present. + if amrIface, ok := token.Get(string(model.ClaimAMR)); ok { + amrSlice := amrIface.([]interface{}) + for _, amrValue := range amrSlice { + amrStr := amrValue.(string) + sessionInfo.SessionAMR = append(sessionInfo.SessionAMR, amrStr) + } + } rolesIface, ok := token.Get(string(model.ClaimAuthgearRoles)) if !ok { From a8dafdad9a92cd91ba76da150e7a696b50771a3e Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 13:20:44 +0800 Subject: [PATCH 17/20] Read auth_time in at+jwt --- pkg/portal/session/middleware_session_info.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/portal/session/middleware_session_info.go b/pkg/portal/session/middleware_session_info.go index 795bffa92a..e8ddf58033 100644 --- a/pkg/portal/session/middleware_session_info.go +++ b/pkg/portal/session/middleware_session_info.go @@ -105,7 +105,17 @@ func (m *SessionInfoMiddleware) jwtToSessionInfo(jwkSet jwk.Set, header http.Hea } sessionInfo.UserCanReauthenticate = canReauthenticate.(bool) - // FIXME(auth_time): Include auth_time in JWT access token. + // auth_time is newly added to at+jwt, so it may not be present. + if authTimeIface, ok := token.Get(string(model.ClaimAuthTime)); ok { + switch v := authTimeIface.(type) { + case float64: + sessionInfo.AuthenticatedAt = time.Unix(int64(v), 0).UTC() + case int64: + sessionInfo.AuthenticatedAt = time.Unix(v, 0).UTC() + default: + panic(fmt.Errorf("unexpected type: %v %T", model.ClaimAuthTime, authTimeIface)) + } + } // amr is newly added to at+jwt, so it may not be present. if amrIface, ok := token.Get(string(model.ClaimAMR)); ok { From 2882363e82d6fa3a2639ea5c00551e56b68d833a Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 15:16:18 +0800 Subject: [PATCH 18/20] Split 8000 and 8010 into 8000,8001,8010,8011 --- CONTRIBUTING.md | 43 +++++++++++++++------ docker-compose.yaml | 2 + nginx.conf | 93 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87ab9aadb1..c2ba6280db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,21 +1,25 @@ -- [Contributing guide](#contributing-guide) +* [Contributing guide](#contributing-guide) * [Install dependencies](#install-dependencies) - + [Install dependencies with asdf and homebrew](#install-dependencies-with-asdf-and-homebrew) - + [Install dependencies with Nix Flakes](#install-dependencies-with-nix-flakes) + * [Install dependencies with asdf and homebrew](#install-dependencies-with-asdf-and-homebrew) + * [Install dependencies with Nix Flakes](#install-dependencies-with-nix-flakes) * [Set up environment](#set-up-environment) * [Set up the database](#set-up-the-database) - * [Set up MinIO](#setup-minio) + * [Set up MinIO](#set-up-minio) * [Run](#run) * [Create an account for yourselves and grant you access to the portal](#create-an-account-for-yourselves-and-grant-you-access-to-the-portal) * [Known issues](#known-issues) - + [Known issues on portal](#known-issues-on-portal) + * [Known issues on portal](#known-issues-on-portal) * [Comment tags](#comment-tags) * [Common tasks](#common-tasks) - + [How to create a new database migration?](#how-to-create-a-new-database-migration) - + [Set up HTTPS to develop some specific features](#set-up-https-to-develop-some-specific-features) - + [Create release tag for a deployment](#create-release-tag-for-a-deployment) - + [Keep dependencies up-to-date](#keep-dependencies-up-to-date) - + [Generate Translation](#generate-translation) + * [How to create a new database migration?](#how-to-create-a-new-database-migration) + * [Set up HTTPS to develop some specific features](#set-up-https-to-develop-some-specific-features) + * [Create release tag for a deployment](#create-release-tag-for-a-deployment) + * [Keep dependencies up\-to\-date](#keep-dependencies-up-to-date) + * [Generate translation](#generate-translation) + * [Set up LDAP for local development](#set-up-ldap-for-local-development) + * [Create a LDAP user](#create-a-ldap-user) + * [Configure Authgear](#configure-authgear) + * [Start with the profile ldap](#start-with-the-profile-ldap) # Contributing guide @@ -165,10 +169,11 @@ use flake issue_jwt_access_token: true access_token_lifetime_seconds: 900 redirect_uris: - # This redirect URI is used by the portal development server. + # See nginx.conf for the difference between 8000, 8001, 8010, and 8011 - "http://portal.localhost:8000/oauth-redirect" - # This redirect URI is used by the portal production build. + - "http://portal.localhost:8001/oauth-redirect" - "http://portal.localhost:8010/oauth-redirect" + - "http://portal.localhost:8011/oauth-redirect" # This redirect URI is used by the iOS and Android demo app. - "com.authgear.example://host/path" # This redirect URI is used by the React Native demo app. @@ -493,3 +498,17 @@ To start them, you need to add `--profile ldap` to `docker compose up -d`, like ``` docker compose --profile ldap up -d ``` + +## Switching between sessionType=refresh_token and sessionType=cookie + +The default configuration + +- Accessing the portal at port 8000 or 8010 +- AUTHGEAR_WEB_SDK_SESSION_TYPE in .env.example + +assumes sessionType=refresh_token. + +In case you need to switch to sessionType=cookie, you + +- Use `AUTHGEAR_WEB_SDK_SESSION_TYPE=cookie` in your .env +- Access the portal at port 8001 or 8011 diff --git a/docker-compose.yaml b/docker-compose.yaml index 456fcb5a2b..516e565447 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -82,7 +82,9 @@ services: - ./tls-cert.pem:/etc/nginx/tls-cert.pem ports: - "8000:8000" + - "8001:8001" - "8010:8010" + - "8011:8011" - "3100:3100" - "443:443" diff --git a/nginx.conf b/nginx.conf index 9df115ee21..e17eb7641b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -79,6 +79,7 @@ http { } } + # vite dev server, sessionType=refresh_token server { server_name _; listen 8000; @@ -91,6 +92,98 @@ http { proxy_set_header Connection $connection_upgrade; } + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } + + # vite dev server, sessionType=cookie + server { + server_name _; + listen 8001; + + location / { + proxy_pass http://host.docker.internal:1234; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_set_header Host $http_host; + + auth_request /_auth; + + auth_request_set $x_authgear_session_valid $upstream_http_x_authgear_session_valid; + auth_request_set $x_authgear_user_id $upstream_http_x_authgear_user_id; + auth_request_set $x_authgear_user_anonymous $upstream_http_x_authgear_user_anonymous; + auth_request_set $x_authgear_user_verified $upstream_http_x_authgear_user_verified; + auth_request_set $x_authgear_session_acr $upstream_http_x_authgear_session_acr; + auth_request_set $x_authgear_session_amr $upstream_http_x_authgear_session_amr; + auth_request_set $x_authgear_session_authenticated_at $upstream_http_x_authgear_session_authenticated_at; + auth_request_set $x_authgear_user_can_reauthenticate $upstream_http_x_authgear_user_can_reauthenticate; + + proxy_set_header x-authgear-session-valid $x_authgear_session_valid; + proxy_set_header x-authgear-user-id $x_authgear_user_id; + proxy_set_header x-authgear-user-anonymous $x_authgear_user_anonymous; + proxy_set_header x-authgear-user-verified $x_authgear_user_verified; + proxy_set_header x-authgear-session-acr $x_authgear_session_acr; + proxy_set_header x-authgear-session-amr $x_authgear_session_amr; + proxy_set_header x-authgear-session-authenticated-at $x_authgear_session_authenticated_at; + proxy_set_header x-authgear-user-can-reauthenticate $x_authgear_user_can_reauthenticate; + } + + location = /_auth { + internal; + proxy_pass http://host.docker.internal:3001/resolve; + proxy_pass_request_body off; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Host "accounts.portal.localhost:3100"; + proxy_set_header Content-Length ""; + } + } + + # portal production build, sessionType=refresh_token + server { + server_name _; + listen 8010; + + location / { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + location ~ ^/api { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + } + + # portal production build, sessionType=cookie + server { + server_name _; + listen 8011; + + location / { + proxy_pass http://host.docker.internal:3003; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + location ~ ^/api { proxy_pass http://host.docker.internal:3003; proxy_set_header Host $http_host; From 5878871b2e400566bea2a7e843e82d31e7acaabf Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Mon, 13 Jan 2025 18:29:45 +0800 Subject: [PATCH 19/20] Upgrade @authgear/web to 2.12.0 --- portal/package-lock.json | 14 +++++++------- portal/package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/portal/package-lock.json b/portal/package-lock.json index 154fc4736d..d37dd78dbd 100644 --- a/portal/package-lock.json +++ b/portal/package-lock.json @@ -11,7 +11,7 @@ ], "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "^2.11.0", + "@authgear/web": "^2.12.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", @@ -281,9 +281,9 @@ } }, "node_modules/@authgear/web": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.11.0.tgz", - "integrity": "sha512-NvWHEBdk09+KNAMyhz5rZZkgzIDbizgHj0jprVVNZ3upqeLwlcR5McLOPfiGPE8kAnLhJz6QEJDPZT0eaKkXVA==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.12.0.tgz", + "integrity": "sha512-OTqWh9rEbkgBfR342HZYQkOfj+DbkrMw36w5JAT1aZl1FU2xJBryPOZvqnZtQQYqP7kR/lgI3ZnTV6ySNcz+Bg==" }, "node_modules/@babel/code-frame": { "version": "7.25.7", @@ -17216,9 +17216,9 @@ } }, "@authgear/web": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.11.0.tgz", - "integrity": "sha512-NvWHEBdk09+KNAMyhz5rZZkgzIDbizgHj0jprVVNZ3upqeLwlcR5McLOPfiGPE8kAnLhJz6QEJDPZT0eaKkXVA==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@authgear/web/-/web-2.12.0.tgz", + "integrity": "sha512-OTqWh9rEbkgBfR342HZYQkOfj+DbkrMw36w5JAT1aZl1FU2xJBryPOZvqnZtQQYqP7kR/lgI3ZnTV6ySNcz+Bg==" }, "@babel/code-frame": { "version": "7.25.7", diff --git a/portal/package.json b/portal/package.json index a892d2f1c3..fd351fe8f0 100644 --- a/portal/package.json +++ b/portal/package.json @@ -59,7 +59,7 @@ }, "dependencies": { "@apollo/client": "3.8.7", - "@authgear/web": "^2.11.0", + "@authgear/web": "^2.12.0", "@elgorditosalsero/react-gtm-hook": "2.7.2", "@fluentui/font-icons-mdl2": "^8.5.55", "@fluentui/merge-styles": "^8.6.13", From 8143b95840df80409aaecf9e4660fcf016b3cc20 Mon Sep 17 00:00:00 2001 From: Louis Chan Date: Tue, 14 Jan 2025 11:04:02 +0800 Subject: [PATCH 20/20] Change console.log to console.info and use substitution string --- portal/src/graphql/portal/Authenticated.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/portal/src/graphql/portal/Authenticated.tsx b/portal/src/graphql/portal/Authenticated.tsx index 95f5069b19..3de421c5d1 100644 --- a/portal/src/graphql/portal/Authenticated.tsx +++ b/portal/src/graphql/portal/Authenticated.tsx @@ -173,7 +173,7 @@ export async function configureAuthgear( options: ConfigureAuthgearOptions ): Promise { // eslint-disable-next-line no-console -- Output the session type to console for easier debugging. - console.log("authgear: sessionType =", options.sessionType); + console.info("authgear: sessionType = %s", options.sessionType); await authgear.configure({ sessionType: options.sessionType, clientID: options.clientID,