diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 508d8d1..7427406 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,5 +3,5 @@ import '$lib/server/drizzle'; import { handleSession } from 'svelte-kit-cookie-session'; export const handle = handleSession({ - secret: SESSION_SECRET -}) + secret: SESSION_SECRET +}); diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts new file mode 100644 index 0000000..562da61 --- /dev/null +++ b/src/lib/server/api-utils.ts @@ -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' } + }); + } +} diff --git a/src/lib/server/crypto-utils.ts b/src/lib/server/crypto-utils.ts index a36b668..3314ab7 100644 --- a/src/lib/server/crypto-utils.ts +++ b/src/lib/server/crypto-utils.ts @@ -69,11 +69,13 @@ export class CryptoUtils { return crypto.createHash('sha256').update(input).digest(); } + static sanitizeS256(input: string) { + return input.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + static createS256(input: string) { - return CryptoUtils.sha256hash(Buffer.from(input).toString('ascii')) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); + return CryptoUtils.sanitizeS256( + CryptoUtils.sha256hash(Buffer.from(input).toString('ascii')).toString('base64') + ); } } diff --git a/src/lib/server/oauth2/.gitignore b/src/lib/server/oauth2/.gitignore deleted file mode 100644 index 4dc77c2..0000000 --- a/src/lib/server/oauth2/.gitignore +++ /dev/null @@ -1 +0,0 @@ -impl.reference/ diff --git a/src/lib/server/oauth2/authorization.ts b/src/lib/server/oauth2/controller/authorization.ts similarity index 77% rename from src/lib/server/oauth2/authorization.ts rename to src/lib/server/oauth2/controller/authorization.ts index 4b3adab..e544577 100644 --- a/src/lib/server/oauth2/authorization.ts +++ b/src/lib/server/oauth2/controller/authorization.ts @@ -1,4 +1,4 @@ -import type { UserSession } from '../users'; +import type { UserSession } from '../../users'; import { InvalidRequest, UnsupportedResponseType, @@ -8,25 +8,25 @@ import { AccessDenied, InvalidGrant, InteractionRequired -} from './error'; -import { OAuth2AccessTokens, OAuth2Clients, OAuth2Codes, OAuth2Tokens } from './model'; -import { OAuth2Users } from './model/user'; -import { OAuth2Response } from './response'; +} from '../error'; +import { + OAuth2AccessTokens, + 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) => { - 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')) { throw new InvalidRequest('redirect_uri field is mandatory for authorization endpoint'); } - redirectUri = url.searchParams.get('redirect_uri') as string; - // req.oauth2.logger.debug('Parameter redirect uri is', redirectUri); + const redirectUri = url.searchParams.get('redirect_uri') as string; + // console.debug('Parameter redirect uri is', redirectUri); if (!url.searchParams.has('client_id')) { 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; - // req.oauth2.logger.debug('Parameter client_id is', clientId); + const clientId = url.searchParams.get('client_id') as string; + // console.debug('Parameter client_id is', clientId); if (!url.searchParams.has('response_type')) { throw new InvalidRequest('response_type field is mandatory for authorization endpoint'); } - responseType = url.searchParams.get('response_type') as string; - // req.oauth2.logger.debug('Parameter response_type is', responseType); + const responseType = url.searchParams.get('response_type') as string; + // console.debug('Parameter response_type is', responseType); // Support multiple types const responseTypes = responseType.split(' '); + let grantTypes: string[] = []; for (const i in responseTypes) { switch (responseTypes[i]) { case 'code': @@ -74,11 +75,11 @@ export class OAuth2Authorization { grantTypes = grantTypes.filter((value, index, self) => self.indexOf(value) === index); // "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'); } - // req.oauth2.logger.debug('Parameter grant_type is', grantTypes.join(' ')); + // console.debug('Parameter grant_type is', grantTypes.join(' ')); const client = await OAuth2Clients.fetchById(clientId); if (!client) { @@ -90,7 +91,7 @@ export class OAuth2Authorization { } else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) { 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 for (const grantType of grantTypes) { @@ -98,19 +99,19 @@ export class OAuth2Authorization { 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)) { 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 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'); } @@ -137,7 +138,7 @@ export class OAuth2Authorization { codeChallengeMethod, redirectUri, responseType - }: Awaited> + }: Awaited> ) => { let resObj: Record = {}; for (const i in grantTypes) { @@ -203,7 +204,7 @@ export class OAuth2Authorization { }; 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 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 ({ @@ -242,7 +243,7 @@ export class OAuth2Authorization { url: URL; request: Request; }) => { - const prehandle = await OAuth2Authorization.prehandle(url, locals); + const prehandle = await OAuth2AuthorizationController.prehandle(url, locals); const { client, scope, user } = prehandle; 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'); } - // req.oauth2.logger.debug('Decision check passed'); + // console.debug('Decision check passed'); await OAuth2Users.saveConsent(user, client, scope); } - return OAuth2Authorization.posthandle(url, prehandle); + return OAuth2AuthorizationController.posthandle(url, prehandle); }; } diff --git a/src/lib/server/oauth2/controller/index.ts b/src/lib/server/oauth2/controller/index.ts new file mode 100644 index 0000000..4a69554 --- /dev/null +++ b/src/lib/server/oauth2/controller/index.ts @@ -0,0 +1,3 @@ +export * from './authorization'; +export * from './introspection'; +export * from './token'; diff --git a/src/lib/server/oauth2/controller/introspection.ts b/src/lib/server/oauth2/controller/introspection.ts new file mode 100644 index 0000000..097ab06 --- /dev/null +++ b/src/lib/server/oauth2/controller/introspection.ts @@ -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); + }; +} diff --git a/src/lib/server/oauth2/controller/token.ts b/src/lib/server/oauth2/controller/token.ts new file mode 100644 index 0000000..68c5919 --- /dev/null +++ b/src/lib/server/oauth2/controller/token.ts @@ -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'); + }; +} diff --git a/src/lib/server/oauth2/controller/tokens/authorizationCode.ts b/src/lib/server/oauth2/controller/tokens/authorizationCode.ts new file mode 100644 index 0000000..3d3cfe8 --- /dev/null +++ b/src/lib/server/oauth2/controller/tokens/authorizationCode.ts @@ -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 { + 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; +} diff --git a/src/lib/server/oauth2/controller/tokens/clientCredentials.ts b/src/lib/server/oauth2/controller/tokens/clientCredentials.ts new file mode 100644 index 0000000..fc2003e --- /dev/null +++ b/src/lib/server/oauth2/controller/tokens/clientCredentials.ts @@ -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 { + 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; +} diff --git a/src/lib/server/oauth2/controller/tokens/index.ts b/src/lib/server/oauth2/controller/tokens/index.ts new file mode 100644 index 0000000..3a60fa0 --- /dev/null +++ b/src/lib/server/oauth2/controller/tokens/index.ts @@ -0,0 +1,3 @@ +export * from './authorizationCode'; +export * from './clientCredentials'; +export * from './refreshToken'; diff --git a/src/lib/server/oauth2/controller/tokens/refreshToken.ts b/src/lib/server/oauth2/controller/tokens/refreshToken.ts new file mode 100644 index 0000000..3b89955 --- /dev/null +++ b/src/lib/server/oauth2/controller/tokens/refreshToken.ts @@ -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 { + 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; +} diff --git a/src/lib/server/oauth2/index.ts b/src/lib/server/oauth2/index.ts index 5b29942..252ecbc 100644 --- a/src/lib/server/oauth2/index.ts +++ b/src/lib/server/oauth2/index.ts @@ -1 +1,4 @@ -export class OAuth2 {} +export * from './controller'; +export * from './model'; +export * from './error'; +export * from './response'; diff --git a/src/lib/server/oauth2/model/index.ts b/src/lib/server/oauth2/model/index.ts index 785fd32..9146e91 100644 --- a/src/lib/server/oauth2/model/index.ts +++ b/src/lib/server/oauth2/model/index.ts @@ -1,2 +1,3 @@ export * from './client'; export * from './tokens'; +export * from './user'; diff --git a/src/lib/server/oauth2/model/tokens.ts b/src/lib/server/oauth2/model/tokens.ts index f743d31..29320a1 100644 --- a/src/lib/server/oauth2/model/tokens.ts +++ b/src/lib/server/oauth2/model/tokens.ts @@ -10,6 +10,7 @@ import { and, eq, sql } from 'drizzle-orm'; import { OAuth2Clients } from './client'; import { Users } from '$lib/server/users'; import { CryptoUtils } from '$lib/server/crypto-utils'; +import { AccessDenied } from '../error'; export type CodeChallengeMethod = 'plain' | 'S256'; @@ -174,11 +175,11 @@ export class OAuth2Codes { return true; } - checkTTL(code: OAuth2Code): boolean { + static checkTTL(code: OAuth2Code): boolean { return new Date(code.expires_at).getTime() > Date.now(); } - getCodeChallenge(code: OAuth2Code) { + static getCodeChallenge(code: OAuth2Code) { return { method: code.code_challenge_method, challenge: code.code_challenge @@ -187,9 +188,14 @@ export class OAuth2Codes { } 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 user = await Users.getById(userId); + const user = userId != null ? await Users.getById(userId) : undefined; const accessToken = CryptoUtils.generateString(64); const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' '); @@ -248,6 +254,46 @@ export class OAuth2AccessTokens { clientIdPub: find.o_auth2_client.client_id }; } + + static async getFromRequest(request: Request, url?: URL): Promise { + 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 { @@ -286,7 +332,7 @@ export class OAuth2RefreshTokens { }; } - async removeByRefreshToken(token: string): Promise { + static async removeByRefreshToken(token: string): Promise { const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.REFRESH_TOKEN); await OAuth2Tokens.remove(find); diff --git a/src/lib/server/oauth2/response.ts b/src/lib/server/oauth2/response.ts index 1c4aa3e..2d2de29 100644 --- a/src/lib/server/oauth2/response.ts +++ b/src/lib/server/oauth2/response.ts @@ -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) { return redirect(302, redirectUri); } @@ -53,10 +60,7 @@ export class OAuth2Response { return redirect(302, redirectUri); } - return OAuth2Response.createResponse(err.status, { - error: err.code, - error_description: err.message - }); + return OAuth2Response.createErrorResponse(err); } static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) { diff --git a/src/routes/api/user/+server.ts b/src/routes/api/user/+server.ts new file mode 100644 index 0000000..0a2a86a --- /dev/null +++ b/src/routes/api/user/+server.ts @@ -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 = { + 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); +}; diff --git a/src/routes/oauth2/authorize/+page.server.ts b/src/routes/oauth2/authorize/+page.server.ts index 0079f97..c9e23f0 100644 --- a/src/routes/oauth2/authorize/+page.server.ts +++ b/src/routes/oauth2/authorize/+page.server.ts @@ -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 { OAuth2Response } from '$lib/server/oauth2/response.js'; import { Users } from '$lib/server/users'; @@ -9,7 +9,7 @@ export const actions = { locals.user = await Users.readSessionOrRedirect(locals, url); try { - return await OAuth2Authorization.actionRequest({ request, locals, url }); + return await OAuth2AuthorizationController.actionRequest({ request, locals, url }); } catch (err) { if (err instanceof OAuth2Error) { const obj = OAuth2Response.errorPlain( @@ -29,7 +29,7 @@ export const load = async ({ locals, url }) => { locals.user = await Users.readSessionOrRedirect(locals, url); try { - return await OAuth2Authorization.getRequest({ locals, url }); + return await OAuth2AuthorizationController.getRequest({ locals, url }); } catch (err) { if (err instanceof OAuth2Error) { return OAuth2Response.errorPlain(url, err, url.searchParams.get('redirect_uri') || undefined); diff --git a/src/routes/oauth2/introspect/+server.ts b/src/routes/oauth2/introspect/+server.ts new file mode 100644 index 0000000..3662011 --- /dev/null +++ b/src/routes/oauth2/introspect/+server.ts @@ -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; + } +}; diff --git a/src/routes/oauth2/token/+server.ts b/src/routes/oauth2/token/+server.ts new file mode 100644 index 0000000..4eb388d --- /dev/null +++ b/src/routes/oauth2/token/+server.ts @@ -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; + } +};