oauth2 tokens
This commit is contained in:
parent
6222b7ba18
commit
610b3a8c09
@ -4,4 +4,4 @@ import { handleSession } from 'svelte-kit-cookie-session';
|
||||
|
||||
export const handle = handleSession({
|
||||
secret: SESSION_SECRET
|
||||
})
|
||||
});
|
||||
|
8
src/lib/server/api-utils.ts
Normal file
8
src/lib/server/api-utils.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
}
|
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
1
src/lib/server/oauth2/.gitignore
vendored
1
src/lib/server/oauth2/.gitignore
vendored
@ -1 +0,0 @@
|
||||
impl.reference/
|
@ -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<ReturnType<typeof OAuth2Authorization.prehandle>>
|
||||
}: Awaited<ReturnType<typeof OAuth2AuthorizationController.prehandle>>
|
||||
) => {
|
||||
let resObj: Record<string, string | number> = {};
|
||||
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);
|
||||
};
|
||||
}
|
3
src/lib/server/oauth2/controller/index.ts
Normal file
3
src/lib/server/oauth2/controller/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './authorization';
|
||||
export * from './introspection';
|
||||
export * from './token';
|
58
src/lib/server/oauth2/controller/introspection.ts
Normal file
58
src/lib/server/oauth2/controller/introspection.ts
Normal 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);
|
||||
};
|
||||
}
|
97
src/lib/server/oauth2/controller/token.ts
Normal file
97
src/lib/server/oauth2/controller/token.ts
Normal 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');
|
||||
};
|
||||
}
|
140
src/lib/server/oauth2/controller/tokens/authorizationCode.ts
Normal file
140
src/lib/server/oauth2/controller/tokens/authorizationCode.ts
Normal 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;
|
||||
}
|
44
src/lib/server/oauth2/controller/tokens/clientCredentials.ts
Normal file
44
src/lib/server/oauth2/controller/tokens/clientCredentials.ts
Normal 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;
|
||||
}
|
3
src/lib/server/oauth2/controller/tokens/index.ts
Normal file
3
src/lib/server/oauth2/controller/tokens/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './authorizationCode';
|
||||
export * from './clientCredentials';
|
||||
export * from './refreshToken';
|
99
src/lib/server/oauth2/controller/tokens/refreshToken.ts
Normal file
99
src/lib/server/oauth2/controller/tokens/refreshToken.ts
Normal 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;
|
||||
}
|
@ -1 +1,4 @@
|
||||
export class OAuth2 {}
|
||||
export * from './controller';
|
||||
export * from './model';
|
||||
export * from './error';
|
||||
export * from './response';
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './client';
|
||||
export * from './tokens';
|
||||
export * from './user';
|
||||
|
@ -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<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 {
|
||||
@ -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);
|
||||
|
||||
await OAuth2Tokens.remove(find);
|
||||
|
@ -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) {
|
||||
|
64
src/routes/api/user/+server.ts
Normal file
64
src/routes/api/user/+server.ts
Normal 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);
|
||||
};
|
@ -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);
|
||||
|
14
src/routes/oauth2/introspect/+server.ts
Normal file
14
src/routes/oauth2/introspect/+server.ts
Normal 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;
|
||||
}
|
||||
};
|
14
src/routes/oauth2/token/+server.ts
Normal file
14
src/routes/oauth2/token/+server.ts
Normal 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;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user