Skip to content

Commit 67a9e4e

Browse files
committed
Instantly authorize OAuth2 requests
1 parent 598ef0b commit 67a9e4e

File tree

6 files changed

+110
-41
lines changed

6 files changed

+110
-41
lines changed

apps/web/app/(authorize)/authorize/[id]/actions.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { cookies } from 'next/headers';
1515
import { userCookie } from '@/lib/cookie';
1616
import { getFormDataString } from '@/lib/form-data';
1717
import { cancelAuthorizationRequest } from '../helper';
18-
import { AuthorizationRequestData } from '../types';
18+
import { AuthorizationRequest, AuthorizationRequestData } from '../types';
1919
import { OAuth2ErrorCode } from '@/lib/oauth/error';
2020
import { notExpired } from '@/lib/db/helper';
2121

@@ -50,19 +50,18 @@ export async function authorize(id: string, _: FormState, formData: FormData): P
5050
export async function authorizeInternal(
5151
id: string,
5252
accountIds: string[],
53-
emailId: string | undefined
53+
emailId: string | undefined | null
5454
) {
5555
const authorizationRequest = await db.authorizationRequest.findUnique({
5656
where: { id, state: 'Pending', ...notExpired },
57-
});
57+
}) as AuthorizationRequest | null;
5858

5959
if(!authorizationRequest) {
6060
return { error: 'Authorization request not found' };
6161
}
6262

63-
// get data
64-
const data = authorizationRequest.data as unknown as AuthorizationRequestData;
65-
const scopes = data.scope.split(' ') as Scope[];
63+
// get scopes
64+
const scopes = authorizationRequest.data.scope.split(' ') as Scope[];
6665

6766
// verify at least one account was selected
6867
if((hasGW2Scopes(scopes) || scopes.includes(Scope.Accounts)) && accountIds.length === 0) {
@@ -104,8 +103,8 @@ export async function authorizeInternal(
104103
data: {
105104
...identifier,
106105
scope: scopes,
107-
redirectUri: authorizationRequest.type === 'OAuth2' ? (data as AuthorizationRequestData.OAuth2).redirect_uri : undefined,
108-
codeChallenge: `${data.code_challenge_method}:${data.code_challenge}`,
106+
redirectUri: authorizationRequest.type === 'OAuth2' ? authorizationRequest.data.redirect_uri : undefined,
107+
codeChallenge: `${authorizationRequest.data.code_challenge_method}:${authorizationRequest.data.code_challenge}`,
109108
token: generateCode(),
110109
expiresAt: expiresAt(60),
111110
accounts: { connect: accountIds.map((id) => ({ id })) },
@@ -121,8 +120,8 @@ export async function authorizeInternal(
121120

122121
switch(authorizationRequest.type) {
123122
case AuthorizationRequestType.OAuth2: {
124-
const url = await createRedirectUrl((data as AuthorizationRequestData.OAuth2).redirect_uri, {
125-
state: data.state,
123+
const url = await createRedirectUrl(authorizationRequest.data.redirect_uri, {
124+
state: authorizationRequest.data.state,
126125
code: authorization.token,
127126
});
128127

@@ -141,7 +140,7 @@ export async function cancelAuthorization(id: string) {
141140

142141
switch(authRequest.type) {
143142
case AuthorizationRequestType.OAuth2: {
144-
const data = authRequest.data as unknown as AuthorizationRequestData<typeof authRequest.type>;
143+
const data = authRequest.data;
145144

146145
const cancelUrl = await createRedirectUrl(data.redirect_uri, {
147146
state: data.state,

apps/web/app/(authorize)/authorize/[id]/page.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ import Link from 'next/link';
2828
import { Select } from '@gw2treasures/ui/components/Form/Select';
2929
import { PageProps } from '@/lib/next';
3030
import { isExpired } from '@/lib/date';
31-
import { AuthorizationRequestData } from '../types';
31+
import { AuthorizationRequest } from '../types';
3232
import { normalizeScopes } from 'app/(authorize)/oauth2/authorize/validate';
33+
import { cancelAuthorizationRequest } from '../helper';
3334

3435
const getPendingAuthorizationRequest = cache(
3536
(id: string) => db.authorizationRequest.findUnique({
@@ -44,7 +45,7 @@ export default async function AuthorizePage({ params }: PageProps<{ id: string }
4445
const returnUrl = `/authorize/${id}`;
4546

4647
// get the request
47-
const authRequest = await getPendingAuthorizationRequest(id);
48+
const authRequest = await getPendingAuthorizationRequest(id) as (AuthorizationRequest & { client: NonNullable<Awaited<ReturnType<typeof getPendingAuthorizationRequest>>>['client'] }) | null;
4849

4950
if(!authRequest) {
5051
return <Notice type="error">Authorization request not found.</Notice>;
@@ -55,7 +56,6 @@ export default async function AuthorizePage({ params }: PageProps<{ id: string }
5556
}
5657

5758
const { client } = authRequest;
58-
const request = authRequest.data as unknown as AuthorizationRequestData;
5959

6060
// get current user
6161
const session = await getSession();
@@ -65,43 +65,45 @@ export default async function AuthorizePage({ params }: PageProps<{ id: string }
6565
const previousAuthorization = session ? await getPreviousAuthorization(client.id, session.userId) : undefined;
6666
const previousScope = new Set(previousAuthorization?.scope as Scope[]);
6767
const previousAccountIds = previousAuthorization?.accounts.map(({ id }) => id) ?? [];
68-
const scopes = new Set(decodeURIComponent(request.scope).split(' ') as Scope[]);
68+
const scopes = new Set(decodeURIComponent(authRequest.data.scope).split(' ') as Scope[]);
6969

7070
// normalize the previous scopes
7171
normalizeScopes(previousScope);
7272

7373
// if `include_granted_scopes` is set add all previous scopes to the current scopes
74-
if(request.include_granted_scopes) {
74+
if(authRequest.data.include_granted_scopes) {
7575
previousScope.forEach((scope) => scopes.add(scope));
7676
}
7777

7878
// normalize the current scopes
7979
normalizeScopes(scopes);
8080

81-
const verifiedAccountsOnly = scopes.has(Scope.Accounts_Verified) && request.verified_accounts_only === 'true';
81+
const verifiedAccountsOnly = scopes.has(Scope.Accounts_Verified) && authRequest.data.verified_accounts_only === 'true';
8282

8383
// get new/existing scopes
8484
const newScopes = Array.from(scopes).filter((scope) => !previousScope.has(scope));
8585
const oldScopes = Array.from(previousScope).filter((scope) => scopes.has(scope));
8686

8787
const redirect_uri = authRequest.type === 'OAuth2' ?
88-
new URL((request as AuthorizationRequestData.OAuth2).redirect_uri)
88+
new URL(authRequest.data.redirect_uri)
8989
: undefined;
9090

9191

9292
// handle prompt!=consent
9393
const allPreviouslyAuthorized = newScopes.length === 0;
9494
let autoAuthorizeState: FormState | undefined;
95-
if(allPreviouslyAuthorized && request.prompt !== 'consent') {
95+
if(allPreviouslyAuthorized && authRequest.data.prompt !== 'consent') {
9696
autoAuthorizeState = await authorizeInternal(id, previousAccountIds, previousAuthorization?.emailId ?? undefined);
9797
}
9898

9999
// handle prompt=none
100-
if(!allPreviouslyAuthorized && request.prompt === 'none') {
100+
if(!allPreviouslyAuthorized && authRequest.data.prompt === 'none') {
101+
await cancelAuthorizationRequest(authRequest.id);
102+
101103
switch(authRequest.type) {
102104
case AuthorizationRequestType.OAuth2: {
103-
const errorUrl = await createRedirectUrl((request as AuthorizationRequestData.OAuth2).redirect_uri, {
104-
state: request.state,
105+
const errorUrl = await createRedirectUrl(authRequest.data.redirect_uri, {
106+
state: authRequest.data.state,
105107
error: OAuth2ErrorCode.access_denied,
106108
error_description: 'user not previously authorized',
107109
});

apps/web/app/(authorize)/authorize/helper.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import { AuthorizationRequest, AuthorizationRequestType, Prisma } from '@gw2me/database';
2-
import { AuthorizationRequestData } from './types';
1+
import { Authorization, AuthorizationRequestType, AuthorizationType, Prisma } from '@gw2me/database';
2+
import { AuthorizationRequestData, AuthorizationRequest } from './types';
33
import { db } from '@/lib/db';
44
import { expiresAt } from '@/lib/date';
55
import { notExpired } from '@/lib/db/helper';
6+
import { getSession } from '@/lib/session';
7+
import { normalizeScopes } from '../oauth2/authorize/validate';
8+
import { Scope } from '@gw2me/client';
69

710
export async function createAuthorizationRequest<T extends AuthorizationRequestType>(type: T, data: AuthorizationRequestData<T>): Promise<AuthorizationRequest> {
811
// TODO: verify???
@@ -16,7 +19,7 @@ export async function createAuthorizationRequest<T extends AuthorizationRequestT
1619
}
1720
});
1821

19-
return authorizationRequest;
22+
return authorizationRequest as AuthorizationRequest;
2023
}
2124

2225
export async function cancelAuthorizationRequest(id: string) {
@@ -29,5 +32,33 @@ export async function cancelAuthorizationRequest(id: string) {
2932
throw new Error('Authorization request could not be canceled.');
3033
}
3134

32-
return canceled;
35+
return canceled as AuthorizationRequest;
36+
}
37+
38+
export async function getPreviousAuthorizationMatchingScopes(authorizationRequest: AuthorizationRequest): Promise<false | (Authorization & { accounts: { id: string }[] })> {
39+
const session = await getSession();
40+
41+
// if the user is not logged in, we need to show the auth/login screen
42+
if(!session) {
43+
return false;
44+
}
45+
46+
// get requested scopes
47+
const scopes = new Set(authorizationRequest.data.scope.split(' ') as Scope[]);
48+
normalizeScopes(scopes);
49+
50+
console.log('[getPreviousAuthorizationMatchingScopes]', { clientId: authorizationRequest.clientId, userId: session.userId, scopes });
51+
52+
// get previous authorization
53+
const previousAuthorization = await db.authorization.findFirst({
54+
where: {
55+
clientId: authorizationRequest.clientId,
56+
userId: session.userId,
57+
type: { not: AuthorizationType.Code },
58+
scope: { hasEvery: Array.from(scopes) }
59+
},
60+
include: { accounts: { select: { id: true }}}
61+
});
62+
63+
return previousAuthorization ?? false;
3364
}

apps/web/app/(authorize)/authorize/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-namespace */
2-
import { AuthorizationRequestType } from '@gw2me/database';
2+
import type { AuthorizationRequest as DbAuthorizationRequest, AuthorizationRequestType } from '@gw2me/database';
33

44
export namespace AuthorizationRequestData {
55
interface Common {
@@ -25,3 +25,9 @@ export type AuthorizationRequestData<T extends AuthorizationRequestType = Author
2525
T extends 'OAuth2' ? AuthorizationRequestData.OAuth2 :
2626
T extends 'FedCM' ? AuthorizationRequestData.FedCM :
2727
never;
28+
29+
export namespace AuthorizationRequest {
30+
export type OAuth2 = DbAuthorizationRequest & { type: 'OAuth2', data: AuthorizationRequestData.OAuth2 };
31+
export type FedCM = DbAuthorizationRequest & { type: 'FedCM', data: AuthorizationRequestData.FedCM };
32+
}
33+
export type AuthorizationRequest = AuthorizationRequest.OAuth2 | AuthorizationRequest.FedCM;

apps/web/app/(authorize)/oauth2/authorize/page.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,49 @@ import { validateRequest } from './validate';
33
import { Notice } from '@gw2treasures/ui/components/Notice/Notice';
44
import { AuthorizationRequestType } from '@gw2me/database';
55
import { PageProps } from '@/lib/next';
6-
import { createAuthorizationRequest } from 'app/(authorize)/authorize/helper';
6+
import { cancelAuthorizationRequest, createAuthorizationRequest, getPreviousAuthorizationMatchingScopes } from 'app/(authorize)/authorize/helper';
7+
import { authorizeInternal } from 'app/(authorize)/authorize/[id]/actions';
8+
import { createRedirectUrl } from '@/lib/redirectUrl';
9+
import { OAuth2ErrorCode } from '@/lib/oauth/error';
710

811
export default async function AuthorizePage({ searchParams }: PageProps) {
912
// validate request
1013
const { error, request } = await validateRequest(await searchParams);
1114

1215
// show unrecoverable error
16+
// TODO: convert this page to a route and redirect to an error page instead?
1317
if(error !== undefined) {
1418
return <Notice type="error">{error}</Notice>;
1519
}
1620

1721
// create and redirect to auth request
18-
const { id } = await createAuthorizationRequest(AuthorizationRequestType.OAuth2, request);
19-
redirect(`/authorize/${id}`);
22+
const authorizationRequest = await createAuthorizationRequest(AuthorizationRequestType.OAuth2, request);
23+
24+
// if the prompt was not consent, we can try to instantly authorize the request
25+
if(request.prompt !== 'consent') {
26+
// check if the user has previously authorized the same scopes
27+
const previousAuthorization = await getPreviousAuthorizationMatchingScopes(authorizationRequest);
28+
29+
if(previousAuthorization) {
30+
// authorize the request. If this fails for some reason, we just ignore it and continue to redirect the user to the auth screen
31+
await authorizeInternal(authorizationRequest.id, previousAuthorization.accounts.map(({ id }) => id), previousAuthorization.emailId);
32+
} else if(request.prompt === 'none') {
33+
// if the request has prompt=none, we have to cancel the authorization request and redirect the user back
34+
await cancelAuthorizationRequest(authorizationRequest.id);
35+
36+
const errorUrl = await createRedirectUrl(request.redirect_uri, {
37+
state: request.state,
38+
error: OAuth2ErrorCode.access_denied,
39+
error_description: 'user not previously authorized',
40+
});
41+
42+
redirect(errorUrl.toString());
43+
}
44+
}
45+
46+
redirect(`/authorize/${authorizationRequest.id}`);
2047
}
48+
49+
export const metadata = {
50+
title: 'Authorize'
51+
};

apps/web/app/(authorize)/oauth2/authorize/validate.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ClientType } from '@gw2me/database';
66
import { redirect } from 'next/navigation';
77
import { cache } from 'react';
88
import { createRedirectUrl } from '@/lib/redirectUrl';
9-
import { AuthorizeRequestParams } from 'app/authorize/types';
9+
import type { AuthorizationRequestData } from 'app/(authorize)/authorize/types';
1010

1111
export const getApplicationByClientId = cache(async function getApplicationByClientId(clientId: string | undefined) {
1212
assert(clientId, OAuth2ErrorCode.invalid_request, 'client_id is missing');
@@ -34,12 +34,12 @@ export const getApplicationByClientId = cache(async function getApplicationByCli
3434
return client;
3535
});
3636

37-
async function verifyClientId({ client_id }: Partial<AuthorizeRequestParams>) {
37+
async function verifyClientId({ client_id }: Partial<AuthorizationRequestData.OAuth2>) {
3838
await getApplicationByClientId(client_id);
3939
}
4040

4141
/** @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 */
42-
async function verifyRedirectUri({ client_id, redirect_uri }: Partial<AuthorizeRequestParams>) {
42+
async function verifyRedirectUri({ client_id, redirect_uri }: Partial<AuthorizationRequestData.OAuth2>) {
4343
assert(redirect_uri, OAuth2ErrorCode.invalid_request, 'redirect_uri is missing');
4444

4545
const url = tryOrFail(() => new URL(redirect_uri), OAuth2ErrorCode.invalid_request, 'invalid redirect_uri');
@@ -55,15 +55,15 @@ async function verifyRedirectUri({ client_id, redirect_uri }: Partial<AuthorizeR
5555
}
5656

5757
/** @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1 */
58-
function verifyResponseType({ response_type }: Partial<AuthorizeRequestParams>) {
58+
function verifyResponseType({ response_type }: Partial<AuthorizationRequestData.OAuth2>) {
5959
const supportedResponseTypes = ['code'];
6060

6161
assert(response_type, OAuth2ErrorCode.invalid_request, 'missing response_type');
6262
assert(supportedResponseTypes.includes(response_type), OAuth2ErrorCode.unsupported_response_type, 'response_type is unsupported');
6363
}
6464

6565
/** @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 */
66-
function verifyScopes({ scope }: Partial<AuthorizeRequestParams>) {
66+
function verifyScopes({ scope }: Partial<AuthorizationRequestData.OAuth2>) {
6767
assert(scope, OAuth2ErrorCode.invalid_request, 'missing scopes');
6868

6969
const validScopes: string[] = Object.values(Scope);
@@ -75,7 +75,7 @@ function verifyScopes({ scope }: Partial<AuthorizeRequestParams>) {
7575
}
7676

7777
/** @see https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 */
78-
async function verifyPKCE({ client_id, code_challenge, code_challenge_method }: Partial<AuthorizeRequestParams>) {
78+
async function verifyPKCE({ client_id, code_challenge, code_challenge_method }: Partial<AuthorizationRequestData.OAuth2>) {
7979
const supportedAlgorithms = ['S256'];
8080

8181
const hasPKCE = !!code_challenge || !!code_challenge_method;
@@ -89,19 +89,19 @@ async function verifyPKCE({ client_id, code_challenge, code_challenge_method }:
8989
fail(client.type === ClientType.Public && !hasPKCE, OAuth2ErrorCode.invalid_request, 'PKCE is required for public clients');
9090
}
9191

92-
function verifyIncludeGrantedScopes({ include_granted_scopes }: Partial<AuthorizeRequestParams>) {
92+
function verifyIncludeGrantedScopes({ include_granted_scopes }: Partial<AuthorizationRequestData.OAuth2>) {
9393
assert(include_granted_scopes === undefined || include_granted_scopes === 'true', OAuth2ErrorCode.invalid_request, 'invalid include_granted_scopes');
9494
}
9595

96-
function verifyPrompt({ prompt }: Partial<AuthorizeRequestParams>) {
96+
function verifyPrompt({ prompt }: Partial<AuthorizationRequestData.OAuth2>) {
9797
assert([undefined, 'none', 'consent'].includes(prompt), OAuth2ErrorCode.invalid_request, 'invalid prompt');
9898
}
9999

100-
function verifyVerifiedAccountsOnly({ verified_accounts_only }: Partial<AuthorizeRequestParams>) {
100+
function verifyVerifiedAccountsOnly({ verified_accounts_only }: Partial<AuthorizationRequestData.OAuth2>) {
101101
assert(verified_accounts_only === undefined || verified_accounts_only === 'true', OAuth2ErrorCode.invalid_request, 'invalid verified_accounts_only');
102102
}
103103

104-
export const validateRequest = cache(async function validateRequest(request: Partial<AuthorizeRequestParams>): Promise<{ error: string, request?: undefined } | { error: undefined, request: AuthorizeRequestParams }> {
104+
export const validateRequest = cache(async function validateRequest(request: Partial<AuthorizationRequestData.OAuth2>): Promise<{ error: string, request?: undefined } | { error: undefined, request: AuthorizationRequestData.OAuth2 }> {
105105
try {
106106
// first verify client_id and redirect_uri
107107
await verifyClientId(request);
@@ -127,7 +127,7 @@ export const validateRequest = cache(async function validateRequest(request: Par
127127
verifyVerifiedAccountsOnly(request),
128128
]);
129129

130-
return { error: undefined, request: request as AuthorizeRequestParams };
130+
return { error: undefined, request: request as AuthorizationRequestData.OAuth2 };
131131
} catch(error) {
132132
let redirect_uri: URL;
133133

0 commit comments

Comments
 (0)