Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OpenID Connect #1248

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/web/app/api/(oauth)/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
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 }

Check warning on line 12 in apps/web/app/api/(oauth)/auth.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing semicolon.

export function assertRequestAuthentication(
client: Client & { secrets: ClientSecret[] },
headers: Headers,
params: Record<string, string | undefined>
): void {
): RequestAuthentication {
const authHeader = headers.get('Authorization');

const authorizationMethods: Record<AuthenticationMethod, boolean> = {
Expand All @@ -26,7 +30,7 @@
// 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
Expand Down Expand Up @@ -64,8 +68,12 @@
where: { id: clientSecret.id },
data: { usedAt: new Date() }
}));

return { method, client_secret };
}
}

return { method: 'none' };
}

function isValidClientSecret(clientSecret: string, saltedHash: string | null): boolean {
Expand Down
56 changes: 56 additions & 0 deletions apps/web/app/api/(oauth)/token/openid.ts
Original file line number Diff line number Diff line change
@@ -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,
}

Check warning on line 14 in apps/web/app/api/(oauth)/token/openid.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing semicolon.
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

Check warning on line 39 in apps/web/app/api/(oauth)/token/openid.ts

View workflow job for this annotation

GitHub Actions / Lint

Expected property shorthand.
};

// 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);
}
17 changes: 12 additions & 5 deletions apps/web/app/api/(oauth)/token/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>): Promise<TokenResponse> {
// get grant_type
Expand All @@ -28,7 +29,7 @@ export async function handleTokenRequest(headers: Headers, params: Record<string
assert(client, OAuth2ErrorCode.invalid_client, 'Invalid client_id');

// make sure request is authenticated
assertRequestAuthentication(client, headers, params);
const requestAuthentication = assertRequestAuthentication(client, headers, params);

switch(grant_type) {
case 'authorization_code': {
Expand Down Expand Up @@ -85,13 +86,19 @@ export async function handleTokenRequest(headers: Headers, params: Record<string
db.authorization.delete({ where: { id: authorization.id }}),
]);

// create id_token
const id_token = client.type === 'Confidential' && scope.includes(Scope.OpenID)
? await createIdToken({ userId, clientId, requestAuthentication, nonce: 'TODO' })
: undefined;

return {
access_token: accessAuthorization.token,
issued_token_type: 'urn:ietf:params:oauth:token-type:access_token',
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_EXPIRATION,
refresh_token: refreshAuthorization?.token,
scope: scope.join(' ')
scope: scope.join(' '),
id_token
};
}

Expand Down
8 changes: 8 additions & 0 deletions apps/web/app/dev/docs/access-tokens/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,14 @@ export default function DevDocsAccessTokensPage() {
<p>
All clients should verify that this parameter exactly matches to prevent mix-up attacks.
</p>

<Headline id="oidc">OpenID Connect</Headline>
<p>
gw2.me supports <ExternalLink href="https://openid.net/">OpenID Connect Core 1.0</ExternalLink>.
To request an OpenID Connect <Code inline>id_token</Code>, the scope <Code inline>openid</Code> has to be included in the authorization request.
The <Code inline>id_token</Code> will be part of the token response and is signed using the <Code inline>HS256</Code> algorithm with the <Code inline>client_secret</Code> as key.
</p>
<p>OpenID Connect is only supported for confidential clients at this time. Dynamic registration is not supported.</p>
</PageLayout>
);
}
Expand Down
7 changes: 6 additions & 1 deletion apps/web/app/dev/docs/scopes/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -22,12 +23,16 @@ export default function DevDocsScopePage() {
<tbody>
<tr>
<td><Code inline>identify</Code></td>
<td>Get the username from /api/user</td>
<td>Get the username in /api/user</td>
</tr>
<tr>
<td><Code inline>email</Code></td>
<td>Include the email in /api/user</td>
</tr>
<tr>
<td><Code inline>openid</Code></td>
<td>Include an <Code inline>id_token</Code> in token response (see <Link href="/dev/docs/access-tokens#oidc">OpenID Connect</Link>)</td>
</tr>
<tr>
<td><Code inline>accounts</Code></td>
<td>Get the list of accounts from /api/accounts. This scope is always implied when any <Code inline>gw2:*</Code> scope is included.</td>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// 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.
1 change: 1 addition & 0 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface TokenResponse {
expires_in: number,
refresh_token?: string,
scope: string,
id_token?: string,
}

export class Gw2MeClient {
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export enum Scope {
Identify = 'identify',
Email = 'email',

OpenID = 'openid',

Accounts = 'accounts',
Accounts_Verified = 'accounts.verified',
Accounts_DisplayName = 'accounts.displayName',
Expand Down
Loading