From 76ea68f8bca5f5eabfd39a8e9b361f38f18c80fa Mon Sep 17 00:00:00 2001
From: darthmaim
Date: Mon, 16 Dec 2024 02:16:40 +0100
Subject: [PATCH 1/3] (WIP) Implement OpenID Connect
---
apps/web/app/api/(oauth)/auth.ts | 8 +++-
apps/web/app/api/(oauth)/token/token.ts | 47 ++++++++++++++++++--
apps/web/app/dev/docs/access-tokens/page.tsx | 8 ++++
apps/web/app/dev/docs/scopes/page.tsx | 7 ++-
apps/web/next-env.d.ts | 2 +-
packages/client/src/client.ts | 1 +
packages/client/src/types.ts | 2 +
7 files changed, 67 insertions(+), 8 deletions(-)
diff --git a/apps/web/app/api/(oauth)/auth.ts b/apps/web/app/api/(oauth)/auth.ts
index f5103314..ff02b1ba 100644
--- a/apps/web/app/api/(oauth)/auth.ts
+++ b/apps/web/app/api/(oauth)/auth.ts
@@ -11,7 +11,7 @@ export function assertRequestAuthentication(
client: Client & { secrets: ClientSecret[] },
headers: Headers,
params: Record
-): void {
+): { client_secret?: string } {
const authHeader = headers.get('Authorization');
const authorizationMethods: Record = {
@@ -26,7 +26,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 {};
}
// if authentication was provided, this needs to be a confidential client
@@ -64,8 +64,12 @@ export function assertRequestAuthentication(
where: { id: clientSecret.id },
data: { usedAt: new Date() }
}));
+
+ return { client_secret };
}
}
+
+ return {};
}
function isValidClientSecret(clientSecret: string, saltedHash: string | null): boolean {
diff --git a/apps/web/app/api/(oauth)/token/token.ts b/apps/web/app/api/(oauth)/token/token.ts
index 3952f7d8..0bac3db7 100644
--- a/apps/web/app/api/(oauth)/token/token.ts
+++ b/apps/web/app/api/(oauth)/token/token.ts
@@ -3,12 +3,13 @@ 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 { getBaseUrlFromHeaders } from '@/lib/url';
-// 7 days
+/** 7 days in seconds */
const ACCESS_TOKEN_EXPIRATION = 604800;
export async function handleTokenRequest(headers: Headers, params: Record): Promise {
@@ -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',
From f8a95836c94367f36ce140721fecf786f63d066e Mon Sep 17 00:00:00 2001
From: darthmaim
Date: Mon, 16 Dec 2024 10:27:22 +0100
Subject: [PATCH 2/3] Refactor openid `id_token` signing method
---
apps/web/app/api/(oauth)/auth.ts | 12 ++++---
apps/web/app/api/(oauth)/token/openid.ts | 38 ++++++++++++++++++++++
apps/web/app/api/(oauth)/token/token.ts | 40 +++---------------------
3 files changed, 50 insertions(+), 40 deletions(-)
create mode 100644 apps/web/app/api/(oauth)/token/openid.ts
diff --git a/apps/web/app/api/(oauth)/auth.ts b/apps/web/app/api/(oauth)/auth.ts
index ff02b1ba..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
-): { client_secret?: string } {
+): 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
@@ -65,11 +69,11 @@ export function assertRequestAuthentication(
data: { usedAt: new Date() }
}));
- return { client_secret };
+ return { method, client_secret };
}
}
- return {};
+ 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..8b37c3c0
--- /dev/null
+++ b/apps/web/app/api/(oauth)/token/openid.ts
@@ -0,0 +1,38 @@
+import { createSigner } from 'fast-jwt';
+import { RequestAuthentication } from '../auth';
+import { getBaseUrlFromHeaders } from '@/lib/url';
+import { ACCESS_TOKEN_EXPIRATION } from './token';
+
+type IdTokenOptions = {
+ clientId: string,
+ requestAuthentication: RequestAuthentication
+ userId: string,
+ authTime: Date,
+ nonce: string,
+}
+export async function createIdToken({ userId, clientId, requestAuthentication, authTime, nonce }: IdTokenOptions) {
+ const { origin: issuer } = await getBaseUrlFromHeaders();
+
+ const issuedAt = Math.floor(Date.now() / 1000);
+
+ const idToken = {
+ iss: issuer,
+ sub: userId,
+ aud: [clientId],
+ exp: issuedAt + ACCESS_TOKEN_EXPIRATION,
+ iat: issuedAt,
+ auth_time: authTime.valueOf(),
+ 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;
+ }
+}
diff --git a/apps/web/app/api/(oauth)/token/token.ts b/apps/web/app/api/(oauth)/token/token.ts
index 0bac3db7..308a5312 100644
--- a/apps/web/app/api/(oauth)/token/token.ts
+++ b/apps/web/app/api/(oauth)/token/token.ts
@@ -7,10 +7,10 @@ import { Scope, TokenResponse } from '@gw2me/client';
import { ClientType, AuthorizationType } from '@gw2me/database';
import { createHash } from 'crypto';
import { assertRequestAuthentication } from '../auth';
-import { getBaseUrlFromHeaders } from '@/lib/url';
+import { createIdToken } from './openid';
/** 7 days in seconds */
-const ACCESS_TOKEN_EXPIRATION = 604800;
+export const ACCESS_TOKEN_EXPIRATION = 604800;
export async function handleTokenRequest(headers: Headers, params: Record): Promise {
// get grant_type
@@ -29,7 +29,7 @@ export async function handleTokenRequest(headers: Headers, params: Record
Date: Mon, 16 Dec 2024 10:49:03 +0100
Subject: [PATCH 3/3] Include `auth_time` in token
---
apps/web/app/api/(oauth)/token/openid.ts | 26 ++++++++++++++++++++----
apps/web/app/api/(oauth)/token/token.ts | 2 +-
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/apps/web/app/api/(oauth)/token/openid.ts b/apps/web/app/api/(oauth)/token/openid.ts
index 8b37c3c0..7c347160 100644
--- a/apps/web/app/api/(oauth)/token/openid.ts
+++ b/apps/web/app/api/(oauth)/token/openid.ts
@@ -2,18 +2,32 @@ 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,
- authTime: Date,
nonce: string,
}
-export async function createIdToken({ userId, clientId, requestAuthentication, authTime, nonce }: IdTokenOptions) {
+export async function createIdToken({ userId, clientId, requestAuthentication, nonce }: IdTokenOptions) {
const { origin: issuer } = await getBaseUrlFromHeaders();
+ const issuedAt = toTimestamp(new Date());
- const issuedAt = Math.floor(Date.now() / 1000);
+ 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,
@@ -21,7 +35,7 @@ export async function createIdToken({ userId, clientId, requestAuthentication, a
aud: [clientId],
exp: issuedAt + ACCESS_TOKEN_EXPIRATION,
iat: issuedAt,
- auth_time: authTime.valueOf(),
+ auth_time: toTimestamp(user.sessions[0].createdAt),
nonce: nonce
};
@@ -36,3 +50,7 @@ export async function createIdToken({ userId, clientId, requestAuthentication, a
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 308a5312..949f97d6 100644
--- a/apps/web/app/api/(oauth)/token/token.ts
+++ b/apps/web/app/api/(oauth)/token/token.ts
@@ -88,7 +88,7 @@ export async function handleTokenRequest(headers: Headers, params: Record