From 131254ac0d92f6129d5ab296d48bb9bfc68906be Mon Sep 17 00:00:00 2001 From: darthmaim Date: Mon, 6 Jan 2025 17:19:20 +0100 Subject: [PATCH] Add support for FedCM fields and params Part of #1173 --- apps/demo/app/fed-cm/fed-cm.tsx | 15 ++--- .../web/app/(fed-cm)/fed-cm/accounts/route.ts | 5 +- apps/web/app/(fed-cm)/fed-cm/assert/route.ts | 61 ++++++++++++++++--- .../app/oauth2/authorize/layout.module.css | 9 +++ apps/web/app/oauth2/authorize/page.tsx | 11 +--- apps/web/app/oauth2/authorize/validate.ts | 13 ++++ packages/client/package.json | 2 +- packages/client/src/fed-cm.ts | 13 +++- 8 files changed, 97 insertions(+), 32 deletions(-) diff --git a/apps/demo/app/fed-cm/fed-cm.tsx b/apps/demo/app/fed-cm/fed-cm.tsx index d842379f7..721bb156d 100644 --- a/apps/demo/app/fed-cm/fed-cm.tsx +++ b/apps/demo/app/fed-cm/fed-cm.tsx @@ -1,11 +1,10 @@ 'use client'; -import { Gw2MeClient } from '@gw2me/client'; +import { Gw2MeClient, Scope } from '@gw2me/client'; import { Button } from '@gw2treasures/ui/components/Form/Button'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState, type FC } from 'react'; import { Notice } from '@gw2treasures/ui/components/Notice/Notice'; -import { Checkbox } from '@gw2treasures/ui/components/Form/Checkbox'; import { Label } from '@gw2treasures/ui/components/Form/Label'; import { Select } from '@gw2treasures/ui/components/Form/Select'; import { FlexRow } from '@gw2treasures/ui/components/Layout/FlexRow'; @@ -22,7 +21,7 @@ export const FedCm: FC = ({ clientId, gw2meUrl }) => { const [abort, setAbort] = useState(); const [error, setError] = useState(); const [mediation, setMediation] = useState('optional'); - const [mode, setMode] = useState(); + const [mode, setMode] = useState<'passive' | 'active'>(); const gw2me = useMemo(() => new Gw2MeClient({ client_id: clientId }, { url: gw2meUrl }), [clientId, gw2meUrl]); // check if this browser supports mode=button @@ -54,7 +53,7 @@ export const FedCm: FC = ({ clientId, gw2meUrl }) => { setAbort(abortController); setError(undefined); - gw2me.fedCM.request({ mode, mediation, signal: abortController.signal }).then((credential) => { + gw2me.fedCM.request({ mode, mediation, signal: abortController.signal, scopes: [Scope.Identify, Scope.Email] }).then((credential) => { setAbort(undefined); if(credential) { @@ -81,9 +80,11 @@ export const FedCm: FC = ({ clientId, gw2meUrl }) => { ({ label: m, value: m }))} value={mode} onChange={setMode as (value: string) => void}/> + + )} diff --git a/apps/web/app/(fed-cm)/fed-cm/accounts/route.ts b/apps/web/app/(fed-cm)/fed-cm/accounts/route.ts index 8e8608544..0bbd93636 100644 --- a/apps/web/app/(fed-cm)/fed-cm/accounts/route.ts +++ b/apps/web/app/(fed-cm)/fed-cm/accounts/route.ts @@ -5,11 +5,11 @@ import { getUrlFromRequest } from '@/lib/url'; import { db } from '@/lib/db'; import { OAuth2ErrorCode } from '@/lib/oauth/error'; import { corsHeaders } from '@/lib/cors-header'; -import { Scope } from '@gw2me/client'; export async function GET(request: NextRequest) { // verify `Sec-Fetch-Dest: webidentity` header is set if(request.headers.get('Sec-Fetch-Dest') !== 'webidentity') { + console.error('[fed-cm/accounts] Sec-Fetch-Dest invalid'); return Response.json( { error: { code: OAuth2ErrorCode.invalid_request, details: 'Missing `Sec-Fetch-Dest: webidentity` header' }}, { status: 400, headers: corsHeaders(request) } @@ -20,6 +20,7 @@ export async function GET(request: NextRequest) { const user = await getUser(); if(!user) { + console.error('[fed-cm/accounts] no session'); return Response.json( { error: { code: OAuth2ErrorCode.access_denied, details: 'no session' }}, { status: 401, headers: corsHeaders(request) } @@ -34,7 +35,7 @@ export async function GET(request: NextRequest) { ] }; const approvedClients = await db.client.findMany({ - where: { authorizations: { some: { type: 'AccessToken', userId: user.id, ...notExpired, scope: { hasEvery: [Scope.Identify, Scope.Email] }}}}, + where: { authorizations: { some: { type: 'AccessToken', userId: user.id, ...notExpired }}}, select: { id: true } }); diff --git a/apps/web/app/(fed-cm)/fed-cm/assert/route.ts b/apps/web/app/(fed-cm)/fed-cm/assert/route.ts index fd08cfb10..4703f0471 100644 --- a/apps/web/app/(fed-cm)/fed-cm/assert/route.ts +++ b/apps/web/app/(fed-cm)/fed-cm/assert/route.ts @@ -8,10 +8,12 @@ import { db } from '@/lib/db'; import { generateCode } from '@/lib/token'; import { expiresAt } from '@/lib/date'; import { Scope } from '@gw2me/client'; +import { normalizeScopes } from 'app/oauth2/authorize/validate'; export async function POST(request: NextRequest) { // verify `Sec-Fetch-Dest: webidentity` header is set if(request.headers.get('Sec-Fetch-Dest') !== 'webidentity') { + console.error('[fed-cm/assert] Sec-Fetch-Dest invalid'); return Response.json( { error: { code: OAuth2ErrorCode.invalid_request, details: 'Missing `Sec-Fetch-Dest: webidentity` header' }}, { status: 400, headers: corsHeaders(request) } @@ -22,6 +24,7 @@ export async function POST(request: NextRequest) { const user = await getUser(); if(!user) { + console.error('[fed-cm/assert] no session'); return Response.json( { error: { code: OAuth2ErrorCode.access_denied, details: 'no session' }}, { status: 401, headers: corsHeaders(request) } @@ -32,10 +35,13 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const clientId = getFormDataString(formData, 'client_id'); const accountId = getFormDataString(formData, 'account_id'); - const disclosureTextShown = getFormDataString(formData, 'disclosure_text_shown') === 'true'; + const disclosureShownFor: ('name' | 'email' | 'picture')[] = getFormDataString(formData, 'disclosure_shown_for')?.split(',') + ?? getFormDataString(formData, 'disclosure_text_shown') === 'true' ? ['name', 'email', 'picture'] : []; + const params = parseParams(getFormDataString(formData, 'params')); const origin = request.headers.get('Origin'); if(!clientId || !accountId || accountId !== user.id || !origin) { + console.error('[fed-cm/assert] invalid request'); return Response.json( { error: { code: OAuth2ErrorCode.invalid_request, details: 'missing required fields' }}, { status: 400, headers: corsHeaders(request) } @@ -50,6 +56,7 @@ export async function POST(request: NextRequest) { // check that application exists if(!client) { + console.error('[fed-cm/assert] invalid client'); return Response.json( { error: { code: OAuth2ErrorCode.invalid_client, details: 'invalid client_id' }}, { status: 404, headers: corsHeaders(request) } @@ -59,6 +66,7 @@ export async function POST(request: NextRequest) { // verify origin matches a registered callback url const validOrigin = client.callbackUrls.some((url) => new URL(url).origin === origin); if(!validOrigin) { + console.error('[fed-cm/assert] invalid origin'); return Response.json( { error: { code: OAuth2ErrorCode.invalid_request, details: 'wrong origin' }}, { status: 400, headers: corsHeaders(request) } @@ -71,21 +79,39 @@ export async function POST(request: NextRequest) { select: { scope: true, accounts: { select: { id: true }}} }); + // get previously authorized scopes + const previousScopes = new Set(previousAuthorization?.scope as Scope[]); + + // get requested scopes if params.scopes is set, otherwise default to Identify+Email + const requestedScopes = new Set(params.scope?.split(' ') as Scope[] ?? [Scope.Identify, Scope.Email]); + normalizeScopes(requestedScopes); + // always include previous scopes if available (as if `include_granted_scopes` is set during OAuth authorization) - const scopes = new Set(previousAuthorization?.scope as Scope[]); + const scopes = previousScopes.union(requestedScopes); + + // get new scopes + const undisclosedNewScopes = scopes.difference(previousScopes); + + // iterate over disclosed fields and remove corresponding scopes from undisclosed set + for(const disclosure of disclosureShownFor) { + if(disclosure === 'email') { + undisclosedNewScopes.delete(Scope.Email); + } + if(disclosure === 'name') { + undisclosedNewScopes.delete(Scope.Identify); + } + } - // if the disclose text was not shown don't add additional scopes - if(!disclosureTextShown && (!scopes.has(Scope.Identify) || !scopes.has(Scope.Email))) { + // make sure all scopes were either previously authorized or disclosed + if(undisclosedNewScopes.size > 0) { + // TODO: use continue_on to display auth screen + console.error('[fed-cm/assert] undisclosed scopes', undisclosedNewScopes); return Response.json( - { error: { code: OAuth2ErrorCode.invalid_scope, details: 'disclosure_text_shown = false and previous authorization does not include scopes "identify email"' }}, + { error: { code: OAuth2ErrorCode.invalid_scope, details: 'undisclosed new scopes' }}, { status: 400, headers: corsHeaders(request) } ); } - // always include identify + email until FedCM gets a way to define scopes - scopes.add(Scope.Identify); - scopes.add(Scope.Email); - // create code let authorization: Authorization; @@ -113,7 +139,8 @@ export async function POST(request: NextRequest) { }), ]); } catch(error) { - console.log(error); + console.error('[fed-cm/assert] error'); + console.error(error); return Response.json( { error: { code: OAuth2ErrorCode.server_error }}, @@ -128,3 +155,17 @@ export async function POST(request: NextRequest) { { headers: corsHeaders(request) } ); } + +function parseParams(params?: string): { scope?: string } { + if(!params) { + return {}; + } + + try { + return JSON.parse(params); + } catch { + console.error('Could not parse Fed-CM params as json', params); + } + + return {}; +} diff --git a/apps/web/app/oauth2/authorize/layout.module.css b/apps/web/app/oauth2/authorize/layout.module.css index 273b14038..dc1863314 100644 --- a/apps/web/app/oauth2/authorize/layout.module.css +++ b/apps/web/app/oauth2/authorize/layout.module.css @@ -15,6 +15,15 @@ gap: 16px; } +@media(max-width: 592px) { + .box { + margin: 0; + max-width: initial; + border: none; + box-shadow: none; + } +} + .header { display: grid; grid-template: diff --git a/apps/web/app/oauth2/authorize/page.tsx b/apps/web/app/oauth2/authorize/page.tsx index b91050e99..d8703ee8f 100644 --- a/apps/web/app/oauth2/authorize/page.tsx +++ b/apps/web/app/oauth2/authorize/page.tsx @@ -6,7 +6,7 @@ import styles from './page.module.css'; import { SubmitButton } from '@gw2treasures/ui/components/Form/Buttons/SubmitButton'; import { Icon, IconProp } from '@gw2treasures/ui'; import { FC, ReactNode } from 'react'; -import { getApplicationByClientId, validateRequest } from './validate'; +import { getApplicationByClientId, normalizeScopes, validateRequest } from './validate'; import { hasGW2Scopes } from '@/lib/scope'; import { LinkButton } from '@gw2treasures/ui/components/Form/Button'; import { db } from '@/lib/db'; @@ -234,15 +234,6 @@ function getPreviousAuthorization(clientId: string, userId: string) { }); } -const gw2Scopes = Object.values(Scope).filter((scope) => scope.startsWith('gw2:')); - -function normalizeScopes(scopes: Set): void { - // include `accounts` if any gw2 or sub scope is included - if(gw2Scopes.some((scope) => scopes.has(scope)) || scopes.has(Scope.Accounts_DisplayName) || scopes.has(Scope.Accounts_Verified)) { - scopes.add(Scope.Accounts); - } -} - function renderScopes(scopes: Scope[], user: User & { defaultEmail: null | { id: string }}, emails: UserEmail[], emailId: undefined | string, returnUrl: string) { return (
    diff --git a/apps/web/app/oauth2/authorize/validate.ts b/apps/web/app/oauth2/authorize/validate.ts index 5a22b5f88..c3bb57b13 100644 --- a/apps/web/app/oauth2/authorize/validate.ts +++ b/apps/web/app/oauth2/authorize/validate.ts @@ -54,6 +54,10 @@ async function verifyClientId({ client_id }: Partial) { async function verifyRedirectUri({ client_id, redirect_uri }: Partial) { assert(redirect_uri, OAuth2ErrorCode.invalid_request, 'redirect_uri is missing'); + if(redirect_uri === 'fed-cm') { + return; + } + const url = tryOrFail(() => new URL(redirect_uri), OAuth2ErrorCode.invalid_request, 'invalid redirect_uri'); const client = await getApplicationByClientId(client_id); @@ -162,3 +166,12 @@ export const validateRequest = cache(async function validateRequest(request: Par redirect(redirect_uri.toString()); } }); + + +const gw2Scopes = Object.values(Scope).filter((scope) => scope.startsWith('gw2:')); +export function normalizeScopes(scopes: Set): void { + // include `accounts` if any gw2 or sub scope is included + if(gw2Scopes.some((scope) => scopes.has(scope)) || scopes.has(Scope.Accounts_DisplayName) || scopes.has(Scope.Accounts_Verified)) { + scopes.add(Scope.Accounts); + } +} diff --git a/packages/client/package.json b/packages/client/package.json index 1f9e0871c..5dce8c89c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@gw2me/client", - "version": "0.5.2", + "version": "0.5.3", "description": "gw2.me client library", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/client/src/fed-cm.ts b/packages/client/src/fed-cm.ts index 0a2940066..88f424fbc 100644 --- a/packages/client/src/fed-cm.ts +++ b/packages/client/src/fed-cm.ts @@ -1,8 +1,10 @@ import { Gw2MeError } from './error'; +import { Scope } from './types'; export interface FedCMRequestOptions { + scopes: Scope[], mediation?: CredentialMediationRequirement; - mode?: 'button'; + mode?: 'passive' | 'active'; signal?: AbortSignal; } @@ -19,7 +21,7 @@ export class Gw2MeFedCM { return typeof window !== 'undefined' && 'IdentityCredential' in window; } - request({ mediation, signal, mode }: FedCMRequestOptions) { + request({ scopes, mediation, signal, mode }: FedCMRequestOptions) { if(!this.isSupported()) { throw new Gw2MeError('FedCM is not supported'); } @@ -30,6 +32,13 @@ export class Gw2MeFedCM { providers: [{ configURL: this.#configUrl, clientId: this.#clientId, + fields: [ + scopes.includes(Scope.Identify) && 'name', + scopes.includes(Scope.Email) && 'email', + ].filter(Boolean), + params: { + scope: scopes.join(' ') + } }], mode }