diff --git a/apps/web/app/api/(oauth)/auth.ts b/apps/web/app/api/(oauth)/auth.ts index f5103314..e32fb25f 100644 --- a/apps/web/app/api/(oauth)/auth.ts +++ b/apps/web/app/api/(oauth)/auth.ts @@ -7,11 +7,15 @@ import { ClientType, ClientSecret, Client } from '@gw2me/database'; import { scryptSync, timingSafeEqual } from 'crypto'; import { after } from 'next/server'; +export type RequestAuthentication = + | { method: 'none' } + | { method: 'client_secret_basic' | 'client_secret_post', client_secret: string } + export function assertRequestAuthentication( client: Client & { secrets: ClientSecret[] }, headers: Headers, params: Record -): void { +): RequestAuthentication { const authHeader = headers.get('Authorization'); const authorizationMethods: Record = { @@ -26,7 +30,7 @@ export function assertRequestAuthentication( // no authentication provided if(usedAuthentication.length === 0) { assert(client.type === ClientType.Public, OAuth2ErrorCode.invalid_request, 'Missing authorization for confidential client'); - return; + return { method: 'none' }; } // if authentication was provided, this needs to be a confidential client @@ -64,8 +68,12 @@ export function assertRequestAuthentication( where: { id: clientSecret.id }, data: { usedAt: new Date() } })); + + return { method, client_secret }; } } + + return { method: 'none' }; } function isValidClientSecret(clientSecret: string, saltedHash: string | null): boolean { diff --git a/apps/web/app/api/(oauth)/token/openid.ts b/apps/web/app/api/(oauth)/token/openid.ts new file mode 100644 index 00000000..7c347160 --- /dev/null +++ b/apps/web/app/api/(oauth)/token/openid.ts @@ -0,0 +1,56 @@ +import { createSigner } from 'fast-jwt'; +import { RequestAuthentication } from '../auth'; +import { getBaseUrlFromHeaders } from '@/lib/url'; +import { ACCESS_TOKEN_EXPIRATION } from './token'; +import { db } from '@/lib/db'; +import { assert } from '@/lib/oauth/assert'; +import { OAuth2ErrorCode } from '@/lib/oauth/error'; + +type IdTokenOptions = { + clientId: string, + requestAuthentication: RequestAuthentication + userId: string, + nonce: string, +} +export async function createIdToken({ userId, clientId, requestAuthentication, nonce }: IdTokenOptions) { + const { origin: issuer } = await getBaseUrlFromHeaders(); + const issuedAt = toTimestamp(new Date()); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + // get latest created session to use as auth_time + sessions: { + select: { createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 1 + } + } + }); + assert(user, OAuth2ErrorCode.server_error, 'user not found'); + + const idToken = { + iss: issuer, + sub: userId, + aud: [clientId], + exp: issuedAt + ACCESS_TOKEN_EXPIRATION, + iat: issuedAt, + auth_time: toTimestamp(user.sessions[0].createdAt), + nonce: nonce + }; + + // if the token request was authenticated using a client secret + // use the client secret as symmetric key to sign the JWT + if(requestAuthentication.method === 'client_secret_basic' || requestAuthentication.method === 'client_secret_post') { + const jwt = createSigner({ + algorithm: 'HS256', + key: requestAuthentication.client_secret + })(idToken); + + return jwt; + } +} + +function toTimestamp(date: Date): number { + return Math.floor(date.valueOf() / 1000); +} diff --git a/apps/web/app/api/(oauth)/token/token.ts b/apps/web/app/api/(oauth)/token/token.ts index 3952f7d8..949f97d6 100644 --- a/apps/web/app/api/(oauth)/token/token.ts +++ b/apps/web/app/api/(oauth)/token/token.ts @@ -3,13 +3,14 @@ import { db } from '@/lib/db'; import { assert } from '@/lib/oauth/assert'; import { OAuth2Error, OAuth2ErrorCode } from '@/lib/oauth/error'; import { generateAccessToken, generateRefreshToken } from '@/lib/token'; -import { TokenResponse } from '@gw2me/client'; +import { Scope, TokenResponse } from '@gw2me/client'; import { ClientType, AuthorizationType } from '@gw2me/database'; import { createHash } from 'crypto'; import { assertRequestAuthentication } from '../auth'; +import { createIdToken } from './openid'; -// 7 days -const ACCESS_TOKEN_EXPIRATION = 604800; +/** 7 days in seconds */ +export const ACCESS_TOKEN_EXPIRATION = 604800; export async function handleTokenRequest(headers: Headers, params: Record): Promise { // get grant_type @@ -28,7 +29,7 @@ export async function handleTokenRequest(headers: Headers, params: Record All clients should verify that this parameter exactly matches to prevent mix-up attacks.

+ + OpenID Connect +

+ gw2.me supports OpenID Connect Core 1.0. + To request an OpenID Connect id_token, the scope openid has to be included in the authorization request. + The id_token will be part of the token response and is signed using the HS256 algorithm with the client_secret as key. +

+

OpenID Connect is only supported for confidential clients at this time. Dynamic registration is not supported.

); } diff --git a/apps/web/app/dev/docs/scopes/page.tsx b/apps/web/app/dev/docs/scopes/page.tsx index 2ccd0128..8483f942 100644 --- a/apps/web/app/dev/docs/scopes/page.tsx +++ b/apps/web/app/dev/docs/scopes/page.tsx @@ -3,6 +3,7 @@ import { PageLayout } from '@/components/Layout/PageLayout'; import { PageTitle } from '@/components/Layout/PageTitle'; import { Table } from '@gw2treasures/ui/components/Table/Table'; import styles from '../layout.module.css'; +import Link from 'next/link'; export default function DevDocsScopePage() { return ( @@ -22,12 +23,16 @@ export default function DevDocsScopePage() { identify - Get the username from /api/user + Get the username in /api/user email Include the email in /api/user + + openid + Include an id_token in token response (see OpenID Connect) + accounts Get the list of accounts from /api/accounts. This scope is always implied when any gw2:* scope is included. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 40c3d680..1b3be084 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 7ac24470..d6227ee6 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -32,6 +32,7 @@ export interface TokenResponse { expires_in: number, refresh_token?: string, scope: string, + id_token?: string, } export class Gw2MeClient { diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 9a38283c..5737cc20 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -2,6 +2,8 @@ export enum Scope { Identify = 'identify', Email = 'email', + OpenID = 'openid', + Accounts = 'accounts', Accounts_Verified = 'accounts.verified', Accounts_DisplayName = 'accounts.displayName',