Skip to content

Commit

Permalink
Add support for FedCM fields and params
Browse files Browse the repository at this point in the history
Part of #1173
  • Loading branch information
darthmaim committed Jan 6, 2025
1 parent 002e2cf commit 131254a
Show file tree
Hide file tree
Showing 8 changed files with 97 additions and 32 deletions.
15 changes: 8 additions & 7 deletions apps/demo/app/fed-cm/fed-cm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,7 +21,7 @@ export const FedCm: FC<FedCmProps> = ({ clientId, gw2meUrl }) => {
const [abort, setAbort] = useState<AbortController>();
const [error, setError] = useState<string>();
const [mediation, setMediation] = useState<CredentialMediationRequirement>('optional');
const [mode, setMode] = useState<undefined | 'button'>();
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
Expand Down Expand Up @@ -54,7 +53,7 @@ export const FedCm: FC<FedCmProps> = ({ 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) {
Expand All @@ -81,9 +80,11 @@ export const FedCm: FC<FedCmProps> = ({ clientId, gw2meUrl }) => {
<Select options={['required', 'optional', 'silent'].map((m) => ({ label: m, value: m }))} value={mediation} onChange={setMediation as (value: string) => void}/>
</Label>

<Label label="Mode">
<Checkbox checked={mode === 'button'} onChange={(checked) => setMode(checked ? 'button' : undefined)} disabled={!supportsFedCmMode}>button</Checkbox>
</Label>
{supportsFedCmMode && (
<Label label="Mode">
<Select options={['passive', 'active'].map((m) => ({ label: m, value: m }))} value={mode} onChange={setMode as (value: string) => void}/>
</Label>
)}

<FlexRow>
<Button onClick={handleClick} icon="gw2me">Trigger FedCM</Button>
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/(fed-cm)/fed-cm/accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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) }
Expand All @@ -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 }
});

Expand Down
61 changes: 51 additions & 10 deletions apps/web/app/(fed-cm)/fed-cm/assert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand All @@ -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) }
Expand All @@ -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) }
Expand All @@ -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) }
Expand All @@ -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) }
Expand All @@ -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<Scope>(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;

Expand Down Expand Up @@ -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 }},
Expand All @@ -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 {};
}
9 changes: 9 additions & 0 deletions apps/web/app/oauth2/authorize/layout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 1 addition & 10 deletions apps/web/app/oauth2/authorize/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,15 +234,6 @@ function getPreviousAuthorization(clientId: string, userId: string) {
});
}

const gw2Scopes = Object.values(Scope).filter((scope) => scope.startsWith('gw2:'));

function normalizeScopes(scopes: Set<Scope>): 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 (
<ul className={styles.scopeList}>
Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/oauth2/authorize/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ async function verifyClientId({ client_id }: Partial<AuthorizeRequestParams>) {
async function verifyRedirectUri({ client_id, redirect_uri }: Partial<AuthorizeRequestParams>) {
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);
Expand Down Expand Up @@ -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<Scope>): 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);
}
}
2 changes: 1 addition & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 11 additions & 2 deletions packages/client/src/fed-cm.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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');
}
Expand All @@ -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
}
Expand Down

0 comments on commit 131254a

Please sign in to comment.