oauth2 tokens

This commit is contained in:
Evert Prants 2024-05-18 12:22:05 +03:00
parent 6222b7ba18
commit 610b3a8c09
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 653 additions and 53 deletions

View File

@ -3,5 +3,5 @@ import '$lib/server/drizzle';
import { handleSession } from 'svelte-kit-cookie-session'; import { handleSession } from 'svelte-kit-cookie-session';
export const handle = handleSession({ export const handle = handleSession({
secret: SESSION_SECRET secret: SESSION_SECRET
}) });

View File

@ -0,0 +1,8 @@
export class ApiUtils {
static json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@ -69,11 +69,13 @@ export class CryptoUtils {
return crypto.createHash('sha256').update(input).digest(); return crypto.createHash('sha256').update(input).digest();
} }
static sanitizeS256(input: string) {
return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
static createS256(input: string) { static createS256(input: string) {
return CryptoUtils.sha256hash(Buffer.from(input).toString('ascii')) return CryptoUtils.sanitizeS256(
.toString('base64') CryptoUtils.sha256hash(Buffer.from(input).toString('ascii')).toString('base64')
.replace(/\+/g, '-') );
.replace(/\//g, '_')
.replace(/=+$/, '');
} }
} }

View File

@ -1 +0,0 @@
impl.reference/

View File

@ -1,4 +1,4 @@
import type { UserSession } from '../users'; import type { UserSession } from '../../users';
import { import {
InvalidRequest, InvalidRequest,
UnsupportedResponseType, UnsupportedResponseType,
@ -8,25 +8,25 @@ import {
AccessDenied, AccessDenied,
InvalidGrant, InvalidGrant,
InteractionRequired InteractionRequired
} from './error'; } from '../error';
import { OAuth2AccessTokens, OAuth2Clients, OAuth2Codes, OAuth2Tokens } from './model'; import {
import { OAuth2Users } from './model/user'; OAuth2AccessTokens,
import { OAuth2Response } from './response'; OAuth2Clients,
OAuth2Codes,
OAuth2Tokens,
type CodeChallengeMethod
} from '../model';
import { OAuth2Users } from '../model/user';
import { OAuth2Response } from '../response';
export class OAuth2Authorization { export class OAuth2AuthorizationController {
static prehandle = async (url: URL, locals: App.Locals) => { static prehandle = async (url: URL, locals: App.Locals) => {
let clientId: string | null = null;
let redirectUri: string | null = null;
let responseType: string | null = null;
let grantTypes: string[] = [];
let scope: string[] | null = null;
if (!url.searchParams.has('redirect_uri')) { if (!url.searchParams.has('redirect_uri')) {
throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint');
} }
redirectUri = url.searchParams.get('redirect_uri') as string; const redirectUri = url.searchParams.get('redirect_uri') as string;
// req.oauth2.logger.debug('Parameter redirect uri is', redirectUri); // console.debug('Parameter redirect uri is', redirectUri);
if (!url.searchParams.has('client_id')) { if (!url.searchParams.has('client_id')) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint'); throw new InvalidRequest('client_id field is mandatory for authorization endpoint');
@ -39,18 +39,19 @@ export class OAuth2Authorization {
); );
} }
clientId = url.searchParams.get('client_id') as string; const clientId = url.searchParams.get('client_id') as string;
// req.oauth2.logger.debug('Parameter client_id is', clientId); // console.debug('Parameter client_id is', clientId);
if (!url.searchParams.has('response_type')) { if (!url.searchParams.has('response_type')) {
throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); throw new InvalidRequest('response_type field is mandatory for authorization endpoint');
} }
responseType = url.searchParams.get('response_type') as string; const responseType = url.searchParams.get('response_type') as string;
// req.oauth2.logger.debug('Parameter response_type is', responseType); // console.debug('Parameter response_type is', responseType);
// Support multiple types // Support multiple types
const responseTypes = responseType.split(' '); const responseTypes = responseType.split(' ');
let grantTypes: string[] = [];
for (const i in responseTypes) { for (const i in responseTypes) {
switch (responseTypes[i]) { switch (responseTypes[i]) {
case 'code': case 'code':
@ -74,11 +75,11 @@ export class OAuth2Authorization {
grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index); grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index);
// "None" type cannot be combined with others // "None" type cannot be combined with others
if (grantTypes.length > 1 && grantTypes.indexOf('none') !== -1) { if (grantTypes.length > 1 && grantTypes.includes('none')) {
throw new InvalidRequest('Grant type "none" cannot be combined with other grant types'); throw new InvalidRequest('Grant type "none" cannot be combined with other grant types');
} }
// req.oauth2.logger.debug('Parameter grant_type is', grantTypes.join(' ')); // console.debug('Parameter grant_type is', grantTypes.join(' '));
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
if (!client) { if (!client) {
@ -90,7 +91,7 @@ export class OAuth2Authorization {
} else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) { } else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) {
throw new InvalidRequest('Wrong RedirectUri provided'); throw new InvalidRequest('Wrong RedirectUri provided');
} }
// req.oauth2.logger.debug('redirect_uri check passed'); // console.debug('redirect_uri check passed');
// The client needs to support all grant types // The client needs to support all grant types
for (const grantType of grantTypes) { for (const grantType of grantTypes) {
@ -98,19 +99,19 @@ export class OAuth2Authorization {
throw new UnauthorizedClient('This client does not support grant type ' + grantType); throw new UnauthorizedClient('This client does not support grant type ' + grantType);
} }
} }
// req.oauth2.logger.debug('Grant type check passed'); // console.debug('Grant type check passed');
scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string); const scope = OAuth2Clients.transformScope(url.searchParams.get('scope') as string);
if (!OAuth2Clients.checkScope(client, scope)) { if (!OAuth2Clients.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope'); throw new InvalidScope('Client does not allow access to this scope');
} }
// req.oauth2.logger.debug('Scope check passed'); // console.debug('Scope check passed');
const codeChallenge = url.searchParams.get('code_challenge') as string; const codeChallenge = url.searchParams.get('code_challenge') as string;
const codeChallengeMethod = const codeChallengeMethod =
(url.searchParams.get('code_challenge_method') as 'plain' | 'S256') || 'plain'; (url.searchParams.get('code_challenge_method') as CodeChallengeMethod) || 'plain';
if (codeChallengeMethod && !['plain', 'S256'].includes(codeChallengeMethod)) { if (codeChallengeMethod && !OAuth2Tokens.challengeMethods.includes(codeChallengeMethod)) {
throw new InvalidGrant('Invalid code challenge method'); throw new InvalidGrant('Invalid code challenge method');
} }
@ -137,7 +138,7 @@ export class OAuth2Authorization {
codeChallengeMethod, codeChallengeMethod,
redirectUri, redirectUri,
responseType responseType
}: Awaited<ReturnType<typeof OAuth2Authorization.prehandle>> }: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>>
) => { ) => {
let resObj: Record<string, string | number> = {}; let resObj: Record<string, string | number> = {};
for (const i in grantTypes) { for (const i in grantTypes) {
@ -203,7 +204,7 @@ export class OAuth2Authorization {
}; };
static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => { static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => {
const prehandle = await OAuth2Authorization.prehandle(url, locals); const prehandle = await OAuth2AuthorizationController.prehandle(url, locals);
const { client, scope, user } = prehandle; const { client, scope, user } = prehandle;
const prompt = ((url.searchParams.get('prompt') || '') as string).split(' '); const prompt = ((url.searchParams.get('prompt') || '') as string).split(' ');
@ -230,7 +231,7 @@ export class OAuth2Authorization {
}; };
} }
return OAuth2Authorization.posthandle(url, prehandle); return OAuth2AuthorizationController.posthandle(url, prehandle);
}; };
static actionRequest = async ({ static actionRequest = async ({
@ -242,7 +243,7 @@ export class OAuth2Authorization {
url: URL; url: URL;
request: Request; request: Request;
}) => { }) => {
const prehandle = await OAuth2Authorization.prehandle(url, locals); const prehandle = await OAuth2AuthorizationController.prehandle(url, locals);
const { client, scope, user } = prehandle; const { client, scope, user } = prehandle;
const prompt = ((url.searchParams.get('prompt') || '') as string).split(' '); const prompt = ((url.searchParams.get('prompt') || '') as string).split(' ');
@ -262,11 +263,11 @@ export class OAuth2Authorization {
throw new AccessDenied('User denied access to the resource'); throw new AccessDenied('User denied access to the resource');
} }
// req.oauth2.logger.debug('Decision check passed'); // console.debug('Decision check passed');
await OAuth2Users.saveConsent(user, client, scope); await OAuth2Users.saveConsent(user, client, scope);
} }
return OAuth2Authorization.posthandle(url, prehandle); return OAuth2AuthorizationController.posthandle(url, prehandle);
}; };
} }

View File

@ -0,0 +1,3 @@
export * from './authorization';
export * from './introspection';
export * from './token';

View File

@ -0,0 +1,58 @@
import { InvalidRequest } from '../error';
import { OAuth2AccessTokens } from '../model';
import { OAuth2Response } from '../response';
export class OAuth2IntrospectionController {
static postHandler = async ({ request, url }: { request: Request; url: URL }) => {
const body = await request.json().catch(() => ({}));
let clientId: string | null = null;
let clientSecret: string | null = null;
if (body.client_id && body.client_secret) {
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
console.debug('Client credentials parsed from body parameters ', clientId, clientSecret);
} else {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
}
let pieces = (request.headers.get('authorization') as string).split(' ', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header is corrupted');
}
if (pieces[0] !== 'Basic') {
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
}
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header has corrupted data');
}
clientId = pieces[0];
clientSecret = pieces[1];
console.debug('Client credentials parsed from basic auth header: ', clientId, clientSecret);
}
if (!body.token) {
throw new InvalidRequest('Token not provided in request body');
}
const token = await OAuth2AccessTokens.fetchByToken(body.token);
if (!token) {
throw new InvalidRequest('Token does not exist');
}
const ttl = OAuth2AccessTokens.getTTL(token);
const resObj = {
token_type: 'bearer',
token: token.token,
expires_in: Math.floor(ttl / 1000)
};
return OAuth2Response.response(url, resObj);
};
}

View File

@ -0,0 +1,97 @@
import {
InvalidRequest,
InvalidClient,
UnauthorizedClient,
OAuth2Error,
UnsupportedGrantType,
ServerError
} from '../error';
import { OAuth2Clients } from '../model';
import { OAuth2Response, type OAuth2TokenResponse } from '../response';
import * as tokens from './tokens';
export class OAuth2TokenController {
static postHandler = async ({ url, request }: { url: URL; request: Request }) => {
let clientId: string | null = null;
let clientSecret: string | null = null;
let grantType: string | null = null;
const body = await request.json().catch(() => ({}));
if (body.client_id && body.client_secret) {
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
console.debug('Client credentials parsed from body parameters', clientId, clientSecret);
} else {
if (!request.headers?.has('authorization')) {
throw new InvalidRequest('No authorization header passed');
}
let pieces = (request.headers.get('authorization') as string).split(' ', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header is corrupted');
}
if (pieces[0] !== 'Basic') {
throw new InvalidRequest(`Unsupported authorization method: ${pieces[0]}`);
}
pieces = Buffer.from(pieces[1], 'base64').toString('ascii').split(':', 2);
if (!pieces || pieces.length !== 2) {
throw new InvalidRequest('Authorization header has corrupted data');
}
clientId = pieces[0];
clientSecret = pieces[1];
console.debug('Client credentials parsed from basic auth header:', clientId, clientSecret);
}
if (!body.grant_type) {
throw new InvalidRequest('Request body does not contain grant_type parameter');
}
grantType = body.grant_type as string;
console.debug('Parameter grant_type is', grantType);
const client = await OAuth2Clients.fetchById(clientId);
if (!client) {
throw new InvalidClient('Client not found');
}
const valid = OAuth2Clients.checkSecret(client, clientSecret);
if (!valid) {
throw new UnauthorizedClient('Invalid client secret');
}
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'refresh_token') {
throw new UnauthorizedClient('Invalid grant type for the client');
} else {
console.debug('Grant type check passed');
}
let tokenResponse: OAuth2TokenResponse = {};
try {
switch (grantType) {
case 'authorization_code':
tokenResponse = await tokens.authorizationCode(client, body.code, body.code_verifier);
break;
case 'client_credentials':
tokenResponse = await tokens.clientCredentials(client, body.scope);
break;
case 'refresh_token':
tokenResponse = await tokens.refreshToken(client, body.refresh_token);
break;
default:
throw new UnsupportedGrantType('Grant type does not match any supported type');
}
if (tokenResponse) {
return OAuth2Response.response(url, tokenResponse);
}
} catch (e) {
return OAuth2Response.error(url, e as OAuth2Error);
}
throw new ServerError('Internal error');
};
}

View File

@ -0,0 +1,140 @@
import { CryptoUtils } from '$lib/server/crypto-utils';
import type { OAuth2Client, User } from '$lib/server/drizzle';
import { Users } from '$lib/server/users';
import { InvalidRequest, ServerError, InvalidGrant } from '../../error';
import {
OAuth2AccessTokens,
OAuth2Clients,
OAuth2Codes,
OAuth2RefreshTokens,
OAuth2Tokens,
type OAuth2Code
} from '../../model';
import { OAuth2Users } from '../../model/user';
import type { OAuth2TokenResponse } from '../../response';
/**
* Issue an access token by authorization code
* @param oauth2 - OAuth2 instance
* @param client - OAuth2 client
* @param providedCode - Authorization code
* @returns Access token.
*/
export async function authorizationCode(
client: OAuth2Client,
providedCode: string,
codeVerifier?: string
): Promise<OAuth2TokenResponse> {
const respObj: OAuth2TokenResponse = {
token_type: 'bearer'
};
let code: OAuth2Code | undefined = undefined;
if (!providedCode) {
throw new InvalidRequest('code is mandatory for authorization_code grant type');
}
try {
code = await OAuth2Codes.fetchByCode(providedCode);
} catch (err) {
console.error(err);
throw new ServerError('Failed to call code.fetchByCode function');
}
if (code) {
if (code.clientId !== client.id) {
throw new InvalidGrant('Code was issued by another client');
}
if (!OAuth2Codes.checkTTL(code)) {
throw new InvalidGrant('Code has already expired');
}
} else {
throw new InvalidGrant('Code not found');
}
console.debug('Code fetched', code);
const scope = code.scope || '';
const cleanScope = OAuth2Clients.transformScope(scope);
const userId = code.userId as number;
const clientId = client.client_id;
if (OAuth2Codes.getCodeChallenge) {
const { challenge, method } = OAuth2Codes.getCodeChallenge(code);
if (challenge && method) {
if (!codeVerifier) {
throw new InvalidGrant('Code verifier is required!');
}
if (method === 'plain' && !CryptoUtils.safeCompare(challenge, codeVerifier)) {
throw new InvalidGrant('Invalid code verifier!');
}
if (
method === 'S256' &&
!CryptoUtils.safeCompare(
CryptoUtils.createS256(codeVerifier),
CryptoUtils.sanitizeS256(challenge)
)
) {
throw new InvalidGrant('Invalid code verifier!');
}
}
console.debug('Code passed PCKE check');
}
if (!OAuth2Clients.checkGrantType(client, 'refresh_token')) {
console.debug('Client does not allow grant type refresh_token, skip creation');
} else {
try {
respObj.refresh_token = await OAuth2RefreshTokens.create(userId, clientId, scope);
} catch (err) {
console.error(err);
throw new ServerError('Failed to call refreshToken.create function');
}
}
try {
respObj.access_token = await OAuth2AccessTokens.create(
userId,
clientId,
scope,
OAuth2Tokens.tokenTtl
);
} catch (err) {
console.error(err);
throw new ServerError('Failed to call accessToken.create function');
}
respObj.expires_in = OAuth2Tokens.tokenTtl;
console.debug('Access token saved:', respObj.access_token);
try {
await OAuth2Codes.removeByCode(providedCode);
} catch (err) {
console.error(err);
throw new ServerError('Failed to call code.removeByCode function');
}
if (cleanScope.includes('openid')) {
const user = await Users.getById(userId);
try {
respObj.id_token = await OAuth2Users.issueIdToken(
user as User,
client,
cleanScope,
code.nonce || undefined
);
} catch (err) {
console.error(err);
throw new ServerError('Failed to issue an ID token');
}
}
return respObj;
}

View File

@ -0,0 +1,44 @@
import type { OAuth2Client } from '$lib/server/drizzle';
import { InvalidScope, ServerError } from '../../error';
import { OAuth2AccessTokens, OAuth2Clients, OAuth2Tokens } from '../../model';
import type { OAuth2TokenResponse } from '../../response';
/**
* Issue client access token
* @param oauth2 - OAuth2 instance
* @param client - Client
* @param wantScope - Requested scopes
* @returns Access token
*/
export async function clientCredentials(
client: OAuth2Client,
wantScope: string | string[]
): Promise<OAuth2TokenResponse> {
let scope: string[] = [];
const resObj: OAuth2TokenResponse = {
token_type: 'bearer'
};
scope = OAuth2Clients.transformScope(wantScope);
if (!OAuth2Clients.checkScope(client, scope)) {
throw new InvalidScope('Client does not allow access to this scope');
}
// console.debug('Scope check passed', scope);
try {
resObj.access_token = await OAuth2AccessTokens.create(
null,
client.client_id,
scope,
OAuth2Tokens.tokenTtl
);
} catch (err) {
throw new ServerError('Failed to call accessToken.create function');
}
resObj.expires_in = OAuth2Tokens.tokenTtl;
return resObj;
}

View File

@ -0,0 +1,3 @@
export * from './authorizationCode';
export * from './clientCredentials';
export * from './refreshToken';

View File

@ -0,0 +1,99 @@
import type { OAuth2Client, User } from '$lib/server/drizzle';
import { Users } from '$lib/server/users';
import { InvalidRequest, ServerError, InvalidGrant, InvalidClient } from '../../error';
import {
type OAuth2RefreshToken,
type OAuth2AccessToken,
OAuth2RefreshTokens,
OAuth2AccessTokens,
OAuth2Tokens
} from '../../model';
import type { OAuth2TokenResponse } from '../../response';
/**
* Get a new access token from a refresh token. Scope change may not be requested.
* @param oauth2 - OAuth2 instance
* @param client - OAuth2 client
* @param providedToken - Refresh token
* @returns Access token
*/
export async function refreshToken(
client: OAuth2Client,
providedToken: string
): Promise<OAuth2TokenResponse> {
let user: User | undefined = undefined;
let ttl: number | null = null;
let refreshToken: OAuth2RefreshToken | undefined = undefined;
let accessToken: OAuth2AccessToken | undefined = undefined;
const resObj: OAuth2TokenResponse = {
token_type: 'bearer'
};
if (!providedToken) {
throw new InvalidRequest('refresh_token is mandatory for refresh_token grant type');
}
try {
refreshToken = await OAuth2RefreshTokens.fetchByToken(providedToken);
} catch (err) {
throw new ServerError('Failed to call refreshToken.fetchByToken function');
}
if (!refreshToken) {
throw new InvalidGrant('Refresh token not found');
}
if (refreshToken.clientId !== client.id) {
console.warn(
'Client %s tried to fetch a refresh token which belongs to client %s!',
client.id,
refreshToken.clientId
);
throw new InvalidGrant('Refresh token not found');
}
try {
user = await Users.getById(refreshToken.userId as number);
} catch (err) {
throw new InvalidClient('User not found');
}
if (!user) {
throw new InvalidClient('User not found');
}
try {
accessToken = await OAuth2AccessTokens.fetchByUserIdClientId(user.id, client.client_id);
} catch (err) {
throw new ServerError('Failed to call accessToken.fetchByUserIdClientId function');
}
if (accessToken) {
ttl = OAuth2AccessTokens.getTTL(accessToken);
if (!ttl) {
accessToken = undefined;
} else {
resObj.access_token = accessToken.token;
resObj.expires_in = ttl;
}
}
if (!accessToken) {
try {
resObj.access_token = await OAuth2AccessTokens.create(
user.id,
client.client_id,
refreshToken.scope || '',
OAuth2Tokens.tokenTtl
);
} catch (err) {
throw new ServerError('Failed to call accessToken.create function');
}
resObj.expires_in = OAuth2Tokens.tokenTtl;
}
return resObj;
}

View File

@ -1 +1,4 @@
export class OAuth2 {} export * from './controller';
export * from './model';
export * from './error';
export * from './response';

View File

@ -1,2 +1,3 @@
export * from './client'; export * from './client';
export * from './tokens'; export * from './tokens';
export * from './user';

View File

@ -10,6 +10,7 @@ import { and, eq, sql } from 'drizzle-orm';
import { OAuth2Clients } from './client'; import { OAuth2Clients } from './client';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { CryptoUtils } from '$lib/server/crypto-utils'; import { CryptoUtils } from '$lib/server/crypto-utils';
import { AccessDenied } from '../error';
export type CodeChallengeMethod = 'plain' | 'S256'; export type CodeChallengeMethod = 'plain' | 'S256';
@ -174,11 +175,11 @@ export class OAuth2Codes {
return true; return true;
} }
checkTTL(code: OAuth2Code): boolean { static checkTTL(code: OAuth2Code): boolean {
return new Date(code.expires_at).getTime() > Date.now(); return new Date(code.expires_at).getTime() > Date.now();
} }
getCodeChallenge(code: OAuth2Code) { static getCodeChallenge(code: OAuth2Code) {
return { return {
method: code.code_challenge_method, method: code.code_challenge_method,
challenge: code.code_challenge challenge: code.code_challenge
@ -187,9 +188,14 @@ export class OAuth2Codes {
} }
export class OAuth2AccessTokens { export class OAuth2AccessTokens {
static async create(userId: number, clientId: string, scope: string | string[], ttl: number) { static async create(
userId: number | null,
clientId: string,
scope: string | string[],
ttl: number
) {
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
const user = await Users.getById(userId); const user = userId != null ? await Users.getById(userId) : undefined;
const accessToken = CryptoUtils.generateString(64); const accessToken = CryptoUtils.generateString(64);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' '); const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
@ -248,6 +254,46 @@ export class OAuth2AccessTokens {
clientIdPub: find.o_auth2_client.client_id clientIdPub: find.o_auth2_client.client_id
}; };
} }
static async getFromRequest(request: Request, url?: URL): Promise<OAuth2AccessToken | undefined> {
let token = null;
// Look for token in header
if (request.headers.has('authorization')) {
const pieces = (request.headers.get('authorization') as string).split(' ', 2);
// Check authorization header
if (!pieces || pieces.length !== 2) {
throw new AccessDenied('Wrong authorization header');
}
// Only bearer auth is supported
if (pieces[0].toLowerCase() !== 'bearer') {
throw new AccessDenied('Unsupported authorization method in header');
}
token = pieces[1];
} else if (request.headers.has('x-access-token')) {
token = request.headers.get('x-access-token');
} else if (url?.searchParams.has('access_token')) {
token = url.searchParams.get('access_token') as string;
} else {
const body = await request.json().catch(() => ({}));
if (!body.access_token) {
throw new AccessDenied('Bearer token not found');
}
token = body.access_token;
}
// Try to fetch access token
const object = await OAuth2AccessTokens.fetchByToken(token);
if (!object) {
throw new AccessDenied('Token not found or has expired');
} else if (!OAuth2AccessTokens.checkTTL(object)) {
throw new AccessDenied('Token is expired');
}
return object;
}
} }
export class OAuth2RefreshTokens { export class OAuth2RefreshTokens {
@ -286,7 +332,7 @@ export class OAuth2RefreshTokens {
}; };
} }
async removeByRefreshToken(token: string): Promise<boolean> { static async removeByRefreshToken(token: string): Promise<boolean> {
const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.REFRESH_TOKEN); const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.REFRESH_TOKEN);
await OAuth2Tokens.remove(find); await OAuth2Tokens.remove(find);

View File

@ -29,6 +29,13 @@ export class OAuth2Response {
}); });
} }
static createErrorResponse(err: OAuth2Error) {
return OAuth2Response.createResponse(err.status, {
error: err.code,
error_description: err.message
});
}
static redirect(redirectUri: string) { static redirect(redirectUri: string) {
return redirect(302, redirectUri); return redirect(302, redirectUri);
} }
@ -53,10 +60,7 @@ export class OAuth2Response {
return redirect(302, redirectUri); return redirect(302, redirectUri);
} }
return OAuth2Response.createResponse(err.status, { return OAuth2Response.createErrorResponse(err);
error: err.code,
error_description: err.message
});
} }
static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) { static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) {

View File

@ -0,0 +1,64 @@
import { PUBLIC_URL } from '$env/static/public';
import { ApiUtils } from '$lib/server/api-utils.js';
import type { User } from '$lib/server/drizzle/schema.js';
import { AccessDenied, OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Clients } from '$lib/server/oauth2/model/client.js';
import { OAuth2AccessTokens } from '$lib/server/oauth2/model/tokens.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { Users } from '$lib/server/users/index.js';
export const GET = async ({ request, url, locals }) => {
let user: User | undefined = undefined;
let tokenScopes: string[] | undefined = undefined;
if (locals.session?.data?.user) {
// From session
user = await Users.getBySession(locals.session.data.user);
} else {
// From OAuth2 bearer token
try {
const token = await OAuth2AccessTokens.getFromRequest(request, url);
if (token?.userId) {
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
user = await Users.getById(token.userId);
}
} catch (error) {
if (error instanceof OAuth2Error) {
return OAuth2Response.createErrorResponse(error);
}
throw error;
}
}
if (!user) {
return OAuth2Response.createErrorResponse(new AccessDenied('Access denied'));
}
const scopelessAccess = !tokenScopes || tokenScopes.includes('management');
const userData: Record<string, unknown> = {
id: user.id,
uuid: user.uuid,
username: user.username,
display_name: user.display_name,
// Standard claims
sub: user.uuid,
name: user.display_name,
preferred_username: user.username,
nickname: user.display_name
};
if (scopelessAccess || tokenScopes?.includes('email')) {
userData.email = user.email;
userData.email_verified = true;
}
if ((scopelessAccess || tokenScopes?.includes('picture')) && user.pictureId) {
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
}
// TODO: privileges
return ApiUtils.json(userData);
};

View File

@ -1,4 +1,4 @@
import { OAuth2Authorization } from '$lib/server/oauth2/authorization.js'; import { OAuth2AuthorizationController } from '$lib/server/oauth2/controller/authorization.js';
import { OAuth2Error } from '$lib/server/oauth2/error.js'; import { OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js'; import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
@ -9,7 +9,7 @@ export const actions = {
locals.user = await Users.readSessionOrRedirect(locals, url); locals.user = await Users.readSessionOrRedirect(locals, url);
try { try {
return await OAuth2Authorization.actionRequest({ request, locals, url }); return await OAuth2AuthorizationController.actionRequest({ request, locals, url });
} catch (err) { } catch (err) {
if (err instanceof OAuth2Error) { if (err instanceof OAuth2Error) {
const obj = OAuth2Response.errorPlain( const obj = OAuth2Response.errorPlain(
@ -29,7 +29,7 @@ export const load = async ({ locals, url }) => {
locals.user = await Users.readSessionOrRedirect(locals, url); locals.user = await Users.readSessionOrRedirect(locals, url);
try { try {
return await OAuth2Authorization.getRequest({ locals, url }); return await OAuth2AuthorizationController.getRequest({ locals, url });
} catch (err) { } catch (err) {
if (err instanceof OAuth2Error) { if (err instanceof OAuth2Error) {
return OAuth2Response.errorPlain(url, err, url.searchParams.get('redirect_uri') || undefined); return OAuth2Response.errorPlain(url, err, url.searchParams.get('redirect_uri') || undefined);

View File

@ -0,0 +1,14 @@
import { OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2IntrospectionController } from '$lib/server/oauth2/controller/introspection.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js';
export const POST = async ({ request, url }) => {
try {
return await OAuth2IntrospectionController.postHandler({ request, url });
} catch (error) {
if (error instanceof OAuth2Error) {
return OAuth2Response.error(url, error);
}
throw error;
}
};

View File

@ -0,0 +1,14 @@
import { OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { OAuth2TokenController } from '$lib/server/oauth2/controller/token.js';
export const POST = async ({ request, url }) => {
try {
return await OAuth2TokenController.postHandler({ request, url });
} catch (error) {
if (error instanceof OAuth2Error) {
return OAuth2Response.error(url, error);
}
throw error;
}
};