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

Refactor authorization #1297

Draft
wants to merge 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { getSession } from '@/lib/session';
import { isString } from '@gw2treasures/helper/is';
import { generateCode } from '@/lib/token';
import { Authorization, AuthorizationType } from '@gw2me/database';
import { Authorization, AuthorizationRequestState, AuthorizationRequestType, AuthorizationType } from '@gw2me/database';
import { redirect } from 'next/navigation';
import { hasGW2Scopes } from '@/lib/scope';
import { Scope } from '@gw2me/client';
Expand All @@ -14,6 +14,10 @@
import { cookies } from 'next/headers';
import { userCookie } from '@/lib/cookie';
import { getFormDataString } from '@/lib/form-data';
import { cancelAuthorizationRequest } from '../helper';
import { AuthorizationRequest, AuthorizationRequestData } from '../types';

Check failure on line 18 in apps/web/app/(authorize)/authorize/[id]/actions.ts

View workflow job for this annotation

GitHub Actions / Lint

'AuthorizationRequestData' is defined but never used.
import { OAuth2ErrorCode } from '@/lib/oauth/error';
import { notExpired } from '@/lib/db/helper';

export interface AuthorizeActionParams {
clientId: string,
Expand All @@ -24,7 +28,7 @@
}

// eslint-disable-next-line require-await
export async function authorize(params: AuthorizeActionParams, _: FormState, formData: FormData): Promise<FormState> {
export async function authorize(id: string, _: FormState, formData: FormData): Promise<FormState> {
// get account ids from form
const accountIds = formData.getAll('accounts').filter(isString);

Expand All @@ -40,14 +44,25 @@
cookieStore.set(userCookie(session.userId));
}

return authorizeInternal(params, accountIds, emailId);
return authorizeInternal(id, accountIds, emailId);
}

export async function authorizeInternal(
{ clientId, redirect_uri, scopes, state, codeChallenge }: AuthorizeActionParams,
id: string,
accountIds: string[],
emailId: string | undefined
emailId: string | undefined | null
) {
const authorizationRequest = await db.authorizationRequest.findUnique({
where: { id, state: 'Pending', ...notExpired },
}) as AuthorizationRequest | null;

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

// get scopes
const scopes = authorizationRequest.data.scope.split(' ') as Scope[];

// verify at least one account was selected
if((hasGW2Scopes(scopes) || scopes.includes(Scope.Accounts)) && accountIds.length === 0) {
return { error: 'At least one account has to be selected.' };
Expand All @@ -70,11 +85,16 @@
try {
const identifier = {
type: AuthorizationType.Code,
clientId,
clientId: authorizationRequest.clientId,
userId: session.userId
};

[, authorization] = await db.$transaction([
[,, authorization] = await db.$transaction([
db.authorizationRequest.update({
where: { id },
data: { state: AuthorizationRequestState.Authorized },
}),

// delete old pending authorization codes for this app
db.authorization.deleteMany({ where: identifier }),

Expand All @@ -83,8 +103,8 @@
data: {
...identifier,
scope: scopes,
redirectUri: redirect_uri,
codeChallenge,
redirectUri: authorizationRequest.type === 'OAuth2' ? authorizationRequest.data.redirect_uri : undefined,
codeChallenge: `${authorizationRequest.data.code_challenge_method}:${authorizationRequest.data.code_challenge}`,
token: generateCode(),
expiresAt: expiresAt(60),
accounts: { connect: accountIds.map((id) => ({ id })) },
Expand All @@ -98,12 +118,41 @@
return { error: 'Authorization failed' };
}

// build redirect url with token and state
const url = await createRedirectUrl(redirect_uri, {
state,
code: authorization.token,
});
switch(authorizationRequest.type) {
case AuthorizationRequestType.OAuth2: {
const url = await createRedirectUrl(authorizationRequest.data.redirect_uri, {
state: authorizationRequest.data.state,
code: authorization.token,
});

// redirect back to app
redirect(url.toString());
// redirect back to app
return redirect(url.toString());
}

case AuthorizationRequestType.FedCM: {
return redirect('/fed-cm/authorize');
}
}
}

export async function cancelAuthorization(id: string) {
const authRequest = await cancelAuthorizationRequest(id);

switch(authRequest.type) {
case AuthorizationRequestType.OAuth2: {
const data = authRequest.data;

const cancelUrl = await createRedirectUrl(data.redirect_uri, {
state: data.state,
error: OAuth2ErrorCode.access_denied,
error_description: 'user canceled authorization',
});

return redirect(cancelUrl.toString());
}

case AuthorizationRequestType.FedCM: {
return redirect('/fed-cm/cancel');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { getSession, getUser } from '@/lib/session';
import { Scope } from '@gw2me/client';
import { redirect } from 'next/navigation';
import layoutStyles from './layout.module.css';
import layoutStyles from '../../layout.module.css';
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, normalizeScopes, validateRequest } from './validate';
import { cache, FC, ReactNode } from 'react';
import { hasGW2Scopes } from '@/lib/scope';
import { LinkButton } from '@gw2treasures/ui/components/Form/Button';
import { Button, LinkButton } from '@gw2treasures/ui/components/Form/Button';
import { db } from '@/lib/db';
import { Checkbox } from '@gw2treasures/ui/components/Form/Checkbox';
import { PermissionList } from '@/components/Permissions/PermissionList';
import { Notice } from '@gw2treasures/ui/components/Notice/Notice';
import { AuthorizeActionParams, authorize, authorizeInternal } from './actions';
import { authorize, authorizeInternal, cancelAuthorization } from './actions';
import { Form, FormState } from '@gw2treasures/ui/components/Form/Form';
import { ApplicationImage } from '@/components/Application/ApplicationImage';
import { createRedirectUrl } from '@/lib/redirectUrl';
import { OAuth2ErrorCode } from '@/lib/oauth/error';
import { AuthorizationType, User, UserEmail } from '@gw2me/database';
import { AuthorizationRequestState, AuthorizationRequestType, AuthorizationType, User, UserEmail } from '@gw2me/database';
import { Expandable } from '@/components/Expandable/Expandable';
import { LoginForm } from 'app/login/form';
import { Metadata } from 'next';
Expand All @@ -27,75 +26,94 @@ import { FlexRow } from '@gw2treasures/ui/components/Layout/FlexRow';
import { ExternalLink } from '@gw2treasures/ui/components/Link/ExternalLink';
import Link from 'next/link';
import { Select } from '@gw2treasures/ui/components/Form/Select';
import { PageProps, searchParamsToURLSearchParams } from '@/lib/next';
import { PageProps } from '@/lib/next';
import { isExpired } from '@/lib/date';
import { AuthorizationRequest } from '../types';
import { normalizeScopes } from 'app/(authorize)/oauth2/authorize/validate';
import { cancelAuthorizationRequest } from '../helper';

export default async function AuthorizePage({ searchParams: asyncSearchParams }: PageProps) {
const searchParams = await asyncSearchParams;
const getPendingAuthorizationRequest = cache(
(id: string) => db.authorizationRequest.findUnique({
where: { id, state: AuthorizationRequestState.Pending },
include: { client: { include: { application: { include: { owner: true }}}}},
})
);

// build return url for /account/add?return=X
const returnUrl = `/oauth2/authorize?${searchParamsToURLSearchParams(searchParams).toString()}`;
export default async function AuthorizePage({ params }: PageProps<{ id: string }>) {
const { id } = await params;

// validate request
const { error, request } = await validateRequest(searchParams);
const returnUrl = `/authorize/${id}`;

if(error !== undefined) {
return <Notice type="error">{error}</Notice>;
// get the request
const authRequest = await getPendingAuthorizationRequest(id) as (AuthorizationRequest & { client: NonNullable<Awaited<ReturnType<typeof getPendingAuthorizationRequest>>>['client'] }) | null;

if(!authRequest) {
return <Notice type="error">Authorization request not found.</Notice>;
}

if(isExpired(authRequest.expiresAt)) {
return <Notice type="error">Authorization request expired.</Notice>;
}

const { client } = authRequest;

// get current user
const session = await getSession();
const user = await getUser();

// declare some variables for easier access
const client = await getApplicationByClientId(request.client_id);
const previousAuthorization = session ? await getPreviousAuthorization(request.client_id, session.userId) : undefined;
const previousAuthorization = session ? await getPreviousAuthorization(client.id, session.userId) : undefined;
const previousScope = new Set(previousAuthorization?.scope as Scope[]);
const previousAccountIds = previousAuthorization?.accounts.map(({ id }) => id) ?? [];
const scopes = new Set(decodeURIComponent(request.scope).split(' ') as Scope[]);
const redirect_uri = new URL(request.redirect_uri);
const scopes = new Set(decodeURIComponent(authRequest.data.scope).split(' ') as Scope[]);

// normalize the previous scopes
normalizeScopes(previousScope);

// if `include_granted_scopes` is set add all previous scopes to the current scopes
if(request.include_granted_scopes) {
if(authRequest.data.include_granted_scopes) {
previousScope.forEach((scope) => scopes.add(scope));
}

// normalize the current scopes
normalizeScopes(scopes);

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

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

// build params for the authorize action
const authorizeActionParams: AuthorizeActionParams = {
clientId: request.client_id,
redirect_uri: redirect_uri.toString(),
scopes: Array.from(scopes),
state: request.state,
codeChallenge: request.code_challenge ? `${request.code_challenge_method}:${request.code_challenge}` : undefined,
};
const redirect_uri = authRequest.type === 'OAuth2' ?
new URL(authRequest.data.redirect_uri)
: undefined;


// handle prompt!=consent
const allPreviouslyAuthorized = newScopes.length === 0;
let autoAuthorizeState: FormState | undefined;
if(allPreviouslyAuthorized && request.prompt !== 'consent') {
autoAuthorizeState = await authorizeInternal(authorizeActionParams, previousAccountIds, previousAuthorization?.emailId ?? undefined);
if(allPreviouslyAuthorized && authRequest.data.prompt !== 'consent') {
autoAuthorizeState = await authorizeInternal(id, previousAccountIds, previousAuthorization?.emailId ?? undefined);
}

// handle prompt=none
if(!allPreviouslyAuthorized && request.prompt === 'none') {
const errorUrl = await createRedirectUrl(redirect_uri, {
state: request.state,
error: OAuth2ErrorCode.access_denied,
error_description: 'user not previously authorized',
});

redirect(errorUrl.toString());
if(!allPreviouslyAuthorized && authRequest.data.prompt === 'none') {
await cancelAuthorizationRequest(authRequest.id);

switch(authRequest.type) {
case AuthorizationRequestType.OAuth2: {
const errorUrl = await createRedirectUrl(authRequest.data.redirect_uri, {
state: authRequest.data.state,
error: OAuth2ErrorCode.access_denied,
error_description: 'user not previously authorized',
});

return redirect(errorUrl.toString());
}

case AuthorizationRequestType.FedCM:
return redirect('/fed-cm/cancel');
}
}

// get emails
Expand All @@ -116,15 +134,10 @@ export default async function AuthorizePage({ searchParams: asyncSearchParams }:
})
: [];

// build cancel url
const cancelUrl = await createRedirectUrl(redirect_uri, {
state: request.state,
error: OAuth2ErrorCode.access_denied,
error_description: 'user canceled authorization',
});

// bind parameters to authorize action
const authorizeAction = authorize.bind(null, authorizeActionParams);
const authorizeAction = authorize.bind(null, id);
const cancelAction = cancelAuthorization.bind(null, id);

return (
<>
Expand All @@ -137,7 +150,9 @@ export default async function AuthorizePage({ searchParams: asyncSearchParams }:
<>
<p className={styles.intro}>To authorize this application, you need to log in first.</p>
<LoginForm returnTo={returnUrl}/>
<LinkButton external href={cancelUrl.toString()} flex appearance="tertiary" className={styles.button}>Cancel</LinkButton>
<form action={cancelAction} style={{ display: 'flex' }}>
<SubmitButton flex appearance="tertiary" className={styles.button}>Cancel</SubmitButton>
</form>
</>
) : (
<Form action={authorizeAction} initialState={autoAuthorizeState}>
Expand Down Expand Up @@ -189,32 +204,26 @@ export default async function AuthorizePage({ searchParams: asyncSearchParams }:
</p>

<div className={styles.buttons}>
<LinkButton external href={cancelUrl.toString()} flex className={styles.button}>Cancel</LinkButton>
<Button type="submit" formAction={cancelAction} flex className={styles.button}>Cancel</Button>
<SubmitButton icon="gw2me-outline" type="submit" flex className={styles.authorizeButton}>Authorize {client.application.name}</SubmitButton>
</div>

<div className={styles.redirectNote}>Authorizing will redirect you to <b>{redirect_uri.origin}</b></div>
{redirect_uri && (
<div className={styles.redirectNote}>Authorizing will redirect you to <b>{redirect_uri.origin}</b></div>
)}
</div>
</Form>
)}
</>
);
}

export async function generateMetadata({ searchParams: asyncSearchParams }: PageProps): Promise<Metadata> {
const searchParams = await asyncSearchParams;
const { error, request } = await validateRequest(searchParams);

if(error !== undefined) {
return {
title: error
};
}

const application = await getApplicationByClientId(request.client_id);
export async function generateMetadata({ params }: PageProps<{ id: string }>): Promise<Metadata> {
const { id } = await params;
const authRequest = await getPendingAuthorizationRequest(id);

return {
title: `Authorize ${application.application.name}`
title: `Authorize ${authRequest?.client.application.name}`
};
}

Expand Down
Loading
Loading