beginning authorize

This commit is contained in:
Evert Prants 2024-05-17 23:22:44 +03:00
parent 4a07389cca
commit 6222b7ba18
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
24 changed files with 1333 additions and 43 deletions

9
package-lock.json generated
View File

@ -12,6 +12,7 @@
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
"otplib": "^12.0.1",
@ -3751,6 +3752,14 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/jose": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.3.0.tgz",
"integrity": "sha512-IChe9AtAE79ru084ow8jzkN2lNrG3Ntfiv65Cvj9uOCE2m5LNsdHG+9EbxWxAoWRF9TgDOqLN5jm08++owDVRg==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",

View File

@ -41,6 +41,7 @@
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"drizzle-orm": "^0.30.10",
"jose": "^5.3.0",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
"otplib": "^12.0.1",

2
private/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

4
src/app.d.ts vendored
View File

@ -1,9 +1,10 @@
import type { User } from '$lib/server/drizzle';
import type { UserSession } from '$lib/server/users/types';
import type { Session } from 'svelte-kit-cookie-session';
type SessionData = {
user?: UserSession;
}
};
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
@ -13,6 +14,7 @@ declare global {
interface Locals {
session: Session<SessionData>;
user: User;
}
interface PageData {

View File

@ -1,11 +1,11 @@
<script lang="ts">
export let user: { uuid: string; username: string };
export let cacheBust: number;
export let cacheBust: number | undefined = undefined;
$: avatarSource = `/api/avatar/${user.uuid}?t=${cacheBust}`;
$: avatarSource = `/api/avatar/${user.uuid}${cacheBust ? `?t=${cacheBust}` : ''}`;
</script>
<div class="avatar-wrapper">
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
<div class="image-wrapper">
<img src={avatarSource} alt={user.username} />
</div>
@ -18,7 +18,10 @@
<style>
.avatar-wrapper {
display: flex;
gap: 16px;
&.with-actions {
gap: 16px;
}
}
.image-wrapper {

View File

@ -4,5 +4,7 @@
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"submit": "Submit",
"cancel": "Cancel",
"manage": "Manage"
"manage": "Manage",
"back": "Go back",
"home": "Home page"
}

View File

@ -0,0 +1,16 @@
{
"authorize": {
"title": "Authorize application",
"errorPage": "The authorization URL provided is invalid or malformed. Please forward this message to the developers of the application you came here from:",
"authorize": "Authorize",
"reject": "Reject",
"scope": {
"profile": "Username and display name",
"email": "Email address",
"picture": "Profile picture",
"account": "Password and other account settings",
"management": "Manage Icy Network on your behalf",
"admin": "Commit administrative actions to the extent of your user privileges"
}
}
}

View File

@ -11,6 +11,11 @@ const config = {
locale: 'en',
key: 'account',
loader: async () => await import('./en/account.json')
},
{
locale: 'en',
key: 'oauth2',
loader: async () => await import('./en/oauth2.json')
}
]
};

View File

@ -60,4 +60,20 @@ export class CryptoUtils {
public static async decryptChallenge<T>(challenge: string): Promise<T> {
return JSON.parse(this.decrypt(challenge, CHALLENGE_SECRET));
}
static safeCompare(token: string, token2: string) {
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(token2));
}
static sha256hash(input: string) {
return crypto.createHash('sha256').update(input).digest();
}
static createS256(input: string) {
return CryptoUtils.sha256hash(Buffer.from(input).toString('ascii'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
}

View File

@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm';
import {
mysqlTable,
int,
@ -19,8 +20,8 @@ export const auditLog = mysqlTable('audit_log', {
actor_ip: text('actor_ip'),
actor_ua: text('actor_ua'),
flagged: tinyint('flagged').default(0).notNull(),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
});
@ -31,11 +32,11 @@ export const document = mysqlTable('document', {
slug: text('slug').notNull(),
body: text('body').notNull(),
authorId: int('authorId').references(() => user.id),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
});
@ -53,11 +54,11 @@ export const oauth2Client = mysqlTable(
verified: tinyint('verified').default(0).notNull(),
pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }),
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
},
(table) => {
@ -67,48 +68,64 @@ export const oauth2Client = mysqlTable(
}
);
export type OAuth2Client = typeof oauth2Client.$inferSelect;
export type NewOAuth2Client = typeof oauth2Client.$inferInsert;
export const oauth2ClientAuthorization = mysqlTable('o_auth2_client_authorization', {
id: int('id').autoincrement().notNull(),
scope: text('scope'),
expires_at: timestamp('expires_at', { mode: 'string' }).default('current_timestamp()').notNull(),
expires_at: timestamp('expires_at', { mode: 'date' })
.default(sql`current_timestamp()`)
.notNull(),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
});
export type OAuth2ClientAuthorization = typeof oauth2ClientAuthorization.$inferSelect;
export type NewOAuth2ClientAuthorization = typeof oauth2ClientAuthorization.$inferInsert;
export const oauth2ClientUrl = mysqlTable('o_auth2_client_url', {
id: int('id').autoincrement().notNull(),
url: varchar('url', { length: 255 }).notNull(),
type: mysqlEnum('type', ['redirect_uri', 'terms', 'privacy', 'website']).notNull(),
created_at: timestamp('created_at', { fsp: 6, mode: 'string' })
.default('current_timestamp(6)')
created_at: timestamp('created_at', { fsp: 6, mode: 'date' })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: timestamp('updated_at', { fsp: 6, mode: 'string' })
.default('current_timestamp(6)')
updated_at: timestamp('updated_at', { fsp: 6, mode: 'date' })
.default(sql`current_timestamp(6)`)
.notNull(),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' })
});
export type OAuth2ClientUrl = typeof oauth2ClientUrl.$inferSelect;
export type NewOAuth2ClientUrl = typeof oauth2ClientUrl.$inferInsert;
export const oauth2Token = mysqlTable('o_auth2_token', {
id: int('id').autoincrement().notNull(),
type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(),
token: text('token').notNull(),
scope: text('scope'),
expires_at: timestamp('expires_at', { mode: 'string' }).default('current_timestamp()').notNull(),
expires_at: timestamp('expires_at', { mode: 'date' })
.default(sql`current_timestamp()`)
.notNull(),
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
nonce: text('nonce'),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
pcke: text('pcke')
});
export type OAuth2Token = typeof oauth2Token.$inferSelect;
export type NewOAuth2Token = typeof oauth2Token.$inferInsert;
export const privilege = mysqlTable('privilege', {
id: int('id').autoincrement().notNull(),
name: text('name').notNull()
@ -123,11 +140,11 @@ export const upload = mysqlTable('upload', {
onDelete: 'set null',
onUpdate: 'cascade'
}),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
});
@ -143,18 +160,18 @@ export const user = mysqlTable(
display_name: varchar('display_name', { length: 32 }).notNull(),
password: text('password'),
activated: tinyint('activated').default(0).notNull(),
activity_at: timestamp('activity_at', { mode: 'string' })
.default('current_timestamp()')
activity_at: timestamp('activity_at', { mode: 'date' })
.default(sql`current_timestamp()`)
.notNull(),
pictureId: int('pictureId').references((): AnyMySqlColumn => upload.id, {
onDelete: 'set null',
onUpdate: 'cascade'
}),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull(),
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
},
(table) => {
@ -201,10 +218,10 @@ export const userToken = mysqlTable('user_token', {
'public_key',
'recovery'
]).notNull(),
expires_at: timestamp('expires_at', { mode: 'string' }),
expires_at: timestamp('expires_at', { mode: 'date' }),
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
nonce: text('nonce'),
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
.default('current_timestamp(6)')
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
.default(sql`current_timestamp(6)`)
.notNull()
});

42
src/lib/server/jwt.ts Normal file
View File

@ -0,0 +1,42 @@
import { JWT_ALGORITHM, JWT_EXPIRATION, JWT_ISSUER } from '$env/static/private';
import { readFile } from 'fs/promises';
import { SignJWT, importPKCS8, importSPKI, jwtVerify } from 'jose';
const privateKeyFile = await readFile('private/jwt.private.pem', { encoding: 'utf-8' });
const publicKeyFile = await readFile('private/jwt.public.pem', { encoding: 'utf-8' });
const privateKey = await importPKCS8(privateKeyFile, JWT_ALGORITHM);
const publicKey = await importSPKI(publicKeyFile, JWT_ALGORITHM);
/**
* Generate JWTs using the following commands:
* Private: openssl genpkey -out jwt.private.pem -algorithm RSA -pkeyopt rsa_keygen_bits:2048
* Public: openssl rsa -in jwt.private.pem -pubout -outform PEM -out jwt.public.pem
*/
export class JWT {
static privateKey = privateKey;
static publicKey = publicKey;
static async issue(claims: Record<string, unknown>, subject: string, audience?: string) {
const sign = new SignJWT(claims)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setSubject(subject)
.setExpirationTime(JWT_EXPIRATION)
.setIssuer(JWT_ISSUER);
if (audience) {
sign.setAudience(audience);
}
return sign.sign(JWT.privateKey);
}
static async verify(token: string, subject?: string, audience?: string) {
const { payload } = await jwtVerify(token, JWT.publicKey, {
issuer: JWT_ISSUER,
subject,
audience
});
return payload;
}
}

1
src/lib/server/oauth2/.gitignore vendored Normal file
View File

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

View File

@ -0,0 +1,272 @@
import type { UserSession } from '../users';
import {
InvalidRequest,
UnsupportedResponseType,
InvalidClient,
UnauthorizedClient,
InvalidScope,
AccessDenied,
InvalidGrant,
InteractionRequired
} from './error';
import { OAuth2AccessTokens, OAuth2Clients, OAuth2Codes, OAuth2Tokens } from './model';
import { OAuth2Users } from './model/user';
import { OAuth2Response } from './response';
export class OAuth2Authorization {
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);
if (!url.searchParams.has('client_id')) {
throw new InvalidRequest('client_id field is mandatory for authorization endpoint');
}
// Check for client_secret (prevent passing it)
if (url.searchParams.has('client_secret')) {
throw new InvalidRequest(
'client_secret field should not be passed to the authorization endpoint'
);
}
clientId = url.searchParams.get('client_id') as string;
// req.oauth2.logger.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);
// Support multiple types
const responseTypes = responseType.split(' ');
for (const i in responseTypes) {
switch (responseTypes[i]) {
case 'code':
grantTypes.push('authorization_code');
break;
case 'token':
grantTypes.push('implicit');
break;
case 'id_token':
grantTypes.push('id_token');
break;
case 'none':
grantTypes.push(responseTypes[i]);
break;
default:
throw new UnsupportedResponseType('Unknown response_type parameter passed');
}
}
// Filter out duplicates
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) {
throw new InvalidRequest('Grant type "none" cannot be combined with other grant types');
}
// req.oauth2.logger.debug('Parameter grant_type is', grantTypes.join(' '));
const client = await OAuth2Clients.fetchById(clientId);
if (!client) {
throw new InvalidClient('Client not found');
}
if (!(await OAuth2Clients.getRedirectUrls(client.client_id))?.length) {
throw new UnsupportedResponseType('The client has not set a redirect uri');
} else if (!(await OAuth2Clients.checkRedirectUri(client, redirectUri))) {
throw new InvalidRequest('Wrong RedirectUri provided');
}
// req.oauth2.logger.debug('redirect_uri check passed');
// The client needs to support all grant types
for (const grantType of grantTypes) {
if (!OAuth2Clients.checkGrantType(client, grantType) && grantType !== 'none') {
throw new UnauthorizedClient('This client does not support grant type ' + grantType);
}
}
// req.oauth2.logger.debug('Grant type check passed');
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');
const codeChallenge = url.searchParams.get('code_challenge') as string;
const codeChallengeMethod =
(url.searchParams.get('code_challenge_method') as 'plain' | 'S256') || 'plain';
if (codeChallengeMethod && !['plain', 'S256'].includes(codeChallengeMethod)) {
throw new InvalidGrant('Invalid code challenge method');
}
return {
client,
user: locals.user,
redirectUri,
responseType,
grantTypes,
scope,
codeChallenge,
codeChallengeMethod
};
};
static posthandle = async (
url: URL,
{
client,
grantTypes,
scope,
user,
codeChallenge,
codeChallengeMethod,
redirectUri,
responseType
}: Awaited<ReturnType<typeof OAuth2Authorization.prehandle>>
) => {
let resObj: Record<string, string | number> = {};
for (const i in grantTypes) {
let data = null;
switch (grantTypes[i]) {
case 'authorization_code':
data = await OAuth2Codes.create(
user.id,
client.client_id,
scope,
OAuth2Tokens.codeTtl,
url.searchParams.get('nonce') as string,
codeChallenge,
codeChallengeMethod
);
resObj = { code: data, ...resObj };
break;
case 'implicit':
data = await OAuth2AccessTokens.create(
user.id,
client.client_id,
scope,
OAuth2Tokens.tokenTtl
);
resObj = {
token_type: 'bearer',
access_token: data,
expires_in: OAuth2Tokens.tokenTtl,
...resObj
};
break;
case 'id_token':
if (!scope.includes('openid')) {
break;
}
data = await OAuth2Users.issueIdToken(
user,
client,
scope,
url.searchParams.get('nonce') as string | undefined
);
resObj = {
id_token: data,
...resObj
};
break;
case 'none':
resObj = {};
break;
default:
throw new UnsupportedResponseType('Unknown response_type parameter passed');
}
}
// Return non-code response types as fragment instead of query
return OAuth2Response.responsePlain(url, resObj, redirectUri, responseType !== 'code');
};
static getRequest = async ({ locals, url }: { locals: App.Locals; url: URL }) => {
const prehandle = await OAuth2Authorization.prehandle(url, locals);
const { client, scope, user } = prehandle;
const prompt = ((url.searchParams.get('prompt') || '') as string).split(' ');
let consented = false;
// Check if the user has already consented to this client with this scope
consented = await OAuth2Users.consented(user.id, client.client_id, scope);
if (!consented && prompt.includes('none')) {
throw new InteractionRequired('Interaction required!');
}
// Ask for consent
if (
!consented ||
prompt.includes('login') ||
prompt.includes('consent') ||
prompt.includes('select_account')
) {
const sanitizedClient = await OAuth2Clients.authorizeClientInfo(client, scope);
return {
client: sanitizedClient,
user: locals.session.data.user as UserSession
};
}
return OAuth2Authorization.posthandle(url, prehandle);
};
static actionRequest = async ({
request,
locals,
url
}: {
locals: App.Locals;
url: URL;
request: Request;
}) => {
const prehandle = await OAuth2Authorization.prehandle(url, locals);
const { client, scope, user } = prehandle;
const prompt = ((url.searchParams.get('prompt') || '') as string).split(' ');
// Check if the user has already consented to this client with this scope
const consented = await OAuth2Users.consented(user.id, client.client_id, scope);
if (!consented && prompt.includes('none')) {
throw new InteractionRequired('Interaction required!');
}
// Save consent
if (!consented) {
const body = await request.formData();
if (!body?.has('decision')) {
throw new InvalidRequest('No decision parameter passed');
} else if (body.get('decision') === '0') {
throw new AccessDenied('User denied access to the resource');
}
// req.oauth2.logger.debug('Decision check passed');
await OAuth2Users.saveConsent(user, client, scope);
}
return OAuth2Authorization.posthandle(url, prehandle);
};
}

View File

@ -0,0 +1,103 @@
export class OAuth2Error extends Error {
public name = 'OAuth2AbstractError';
public logLevel = 'error';
constructor(
public code: string,
public message: string,
public status: number
) {
super();
Error.captureStackTrace(this, this.constructor);
}
}
export class AccessDenied extends OAuth2Error {
public name = 'OAuth2AccessDenied';
public logLevel = 'info';
constructor(msg: string) {
super('access_denied', msg, 403);
}
}
export class InvalidClient extends OAuth2Error {
public name = 'OAuth2InvalidClient';
public logLevel = 'info';
constructor(msg: string) {
super('invalid_client', msg, 401);
}
}
export class InvalidGrant extends OAuth2Error {
public name = 'OAuth2InvalidGrant';
public logLevel = 'info';
constructor(msg: string) {
super('invalid_grant', msg, 400);
}
}
export class InvalidRequest extends OAuth2Error {
public name = 'OAuth2InvalidRequest';
public logLevel = 'info';
constructor(msg: string) {
super('invalid_request', msg, 400);
}
}
export class InvalidScope extends OAuth2Error {
public name = 'OAuth2InvalidScope';
public logLevel = 'info';
constructor(msg: string) {
super('invalid_scope', msg, 400);
}
}
export class ServerError extends OAuth2Error {
public name = 'OAuth2ServerError';
public logLevel = 'error';
constructor(msg: string) {
super('server_error', msg, 500);
}
}
export class UnauthorizedClient extends OAuth2Error {
public name = 'OAuth2UnauthorizedClient';
public logLevel = 'info';
constructor(msg: string) {
super('unauthorized_client', msg, 400);
}
}
export class UnsupportedGrantType extends OAuth2Error {
public name = 'OAuth2UnsupportedGrantType';
public logLevel = 'info';
constructor(msg: string) {
super('unsupported_grant_type', msg, 400);
}
}
export class UnsupportedResponseType extends OAuth2Error {
public name = 'OAuth2UnsupportedResponseType';
public logLevel = 'info';
constructor(msg: string) {
super('unsupported_response_type', msg, 400);
}
}
export class InteractionRequired extends OAuth2Error {
public name = 'OAuth2InteractionRequired';
public logLevel = 'info';
constructor(msg: string) {
super('interaction_required', msg, 400);
}
}

View File

@ -0,0 +1 @@
export class OAuth2 {}

View File

@ -0,0 +1,142 @@
import { db, oauth2Client, oauth2ClientUrl, type OAuth2Client } from '$lib/server/drizzle';
import { and, eq } from 'drizzle-orm';
export enum OAuth2ClientURLType {
REDIRECT_URI = 'redirect_uri',
TERMS = 'terms',
PRIVACY = 'privacy',
WEBSITE = 'website'
}
export class OAuth2Clients {
public static availableGrantTypes = [
'authorization_code',
'refresh_token',
'id_token',
'implicit'
];
public static availableScopes = [
'picture',
'profile',
'email',
'privileges',
'management',
'account',
'openid'
];
public static describedScopes = ['email', 'picture', 'account'];
public static alwaysPresentScopes = ['profile'];
static async fetchById(id: string | number) {
const [client] = await db
.select()
.from(oauth2Client)
.where(typeof id === 'string' ? eq(oauth2Client.client_id, id) : eq(oauth2Client.id, id))
.limit(1);
return client;
}
static async getRedirectUrls(
id: string,
type: OAuth2ClientURLType = OAuth2ClientURLType.REDIRECT_URI
) {
return await db
.select()
.from(oauth2ClientUrl)
.innerJoin(oauth2Client, eq(oauth2ClientUrl.clientId, oauth2Client.id))
.where(and(eq(oauth2Client.client_id, id), eq(oauth2ClientUrl.type, type)));
}
static async getClientUrls(client: OAuth2Client) {
return await db
.select()
.from(oauth2ClientUrl)
.where(and(eq(oauth2ClientUrl.clientId, client.id)));
}
static async checkRedirectUri(client: OAuth2Client, url: string) {
return !!(
await db
.select()
.from(oauth2ClientUrl)
.innerJoin(oauth2Client, eq(oauth2ClientUrl.clientId, oauth2Client.id))
.where(
and(
eq(oauth2Client.client_id, client.client_id),
eq(oauth2ClientUrl.type, OAuth2ClientURLType.REDIRECT_URI),
eq(oauth2ClientUrl.url, url)
)
)
)?.length;
}
static checkSecret(client: OAuth2Client, secret: string) {
return client.client_secret === secret;
}
static checkGrantType(client: OAuth2Client, grant: string) {
return client.grants.split(' ').includes(grant);
}
static transformScope(scope: string | string[]): string[] {
return Array.isArray(scope) ? scope : OAuth2Clients.splitScope(scope);
}
static checkScope(client: OAuth2Client, scope: string[]): boolean {
return scope.every((one) => one === 'profile' || client.scope?.includes(one));
}
static splitScope(scope: string | string[]): string[] {
if (!scope) {
return [];
}
if (Array.isArray(scope)) {
return scope;
}
return scope.includes(',') ? scope.split(',').map((item) => item.trim()) : scope.split(' ');
}
static joinScope(scope: string[]): string {
return scope.join(' ');
}
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) {
const links = await OAuth2Clients.getClientUrls(client);
const filteredLinks = links
.filter((link) => link.type !== 'redirect_uri')
.map(({ url, type }) => ({
url,
type
}));
const allowedScopes = [...OAuth2Clients.alwaysPresentScopes];
const disallowedScopes: string[] = [];
OAuth2Clients.describedScopes.forEach((item) => {
if (scope.includes(item)) {
allowedScopes.push(item);
} else {
disallowedScopes.push(item);
}
});
if (scope.includes('management')) {
allowedScopes.push('management', 'admin');
}
// TODO: client picture
return {
links: filteredLinks,
client_id: client.client_id,
title: client.title,
description: client.description,
grants: client.grants,
allowedScopes,
disallowedScopes
};
}
}

View File

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

View File

@ -0,0 +1,296 @@
import {
db,
oauth2Client,
oauth2Token,
type OAuth2Client,
type OAuth2Token,
type User
} from '$lib/server/drizzle';
import { and, eq, sql } from 'drizzle-orm';
import { OAuth2Clients } from './client';
import { Users } from '$lib/server/users';
import { CryptoUtils } from '$lib/server/crypto-utils';
export type CodeChallengeMethod = 'plain' | 'S256';
export enum OAuth2TokenType {
CODE = 'code',
ACCESS_TOKEN = 'access_token',
REFRESH_TOKEN = 'refresh_token'
}
export interface OAuth2Code extends OAuth2Token {
code_challenge?: string;
code_challenge_method?: CodeChallengeMethod;
clientIdPub: string;
}
export interface OAuth2AccessToken extends OAuth2Token {
clientIdPub: string;
}
export interface OAuth2RefreshToken extends OAuth2Token {
clientIdPub: string;
}
export class OAuth2Tokens {
static codeTtl = 3600;
static tokenTtl = 604800;
static refreshTtl = 3.154e7;
static challengeMethods: CodeChallengeMethod[] = ['plain', 'S256'];
static async insert(
token: string,
type: OAuth2TokenType,
client: OAuth2Client,
scope: string,
expiry: Date,
user?: User,
nonce?: string,
pcke?: string
) {
const [retval] = await db.insert(oauth2Token).values({
token,
type,
scope,
expires_at: expiry,
clientId: client.id,
userId: user?.id,
nonce,
pcke
});
const [newToken] = await db
.select()
.from(oauth2Token)
.where(eq(oauth2Token.id, retval.insertId));
return newToken;
}
static async fetchByToken(token: string, type: OAuth2TokenType) {
const [retval] = await db
.select()
.from(oauth2Token)
.where(and(eq(oauth2Token.token, token), eq(oauth2Token.type, type)));
return retval;
}
static async fetchByUserIdClientId(userId: number, clientId: string, type: OAuth2TokenType) {
const [retval] = await db
.select()
.from(oauth2Token)
.innerJoin(oauth2Client, eq(oauth2Token.clientId, oauth2Client.id))
.where(
and(
eq(oauth2Client.client_id, clientId),
eq(oauth2Token.type, type),
eq(oauth2Token.userId, userId)
)
);
return retval;
}
static async wipeClientTokens(client: OAuth2Client, user?: User) {
await db
.delete(oauth2Token)
.where(
and(eq(oauth2Token.clientId, client.id), user ? eq(oauth2Token.userId, user.id) : undefined)
);
}
static async wipeExpiredTokens() {
await db.execute(sql`DELETE FROM o_auth2_token WHERE expires_at < NOW()`);
}
static async remove(token: OAuth2Token) {
await db.delete(oauth2Token).where(eq(oauth2Token.id, token.id));
}
}
export class OAuth2Codes {
static async create(
userId: number,
clientId: string,
scope: string | string[],
ttl: number,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: CodeChallengeMethod
) {
const client = await OAuth2Clients.fetchById(clientId);
const user = await Users.getById(userId);
const accessToken = CryptoUtils.generateString(64);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
const expiresAt = new Date(Date.now() + ttl * 1000);
const pcke =
codeChallenge && codeChallengeMethod
? `${OAuth2Tokens.challengeMethods.indexOf(codeChallengeMethod)}:${codeChallenge}`
: undefined;
await OAuth2Tokens.insert(
accessToken,
OAuth2TokenType.CODE,
client,
scopes,
expiresAt,
user,
nonce,
pcke
);
return accessToken;
}
static async fetchByCode(code: string | OAuth2Token): Promise<OAuth2Code | undefined> {
const findBy = typeof code === 'string' ? code : code.token;
const find = await OAuth2Tokens.fetchByToken(findBy, OAuth2TokenType.CODE);
if (!find) {
return undefined;
}
let codeChallenge: string | undefined;
let codeChallengeMethod: CodeChallengeMethod | undefined;
if (find.pcke) {
codeChallengeMethod = OAuth2Tokens.challengeMethods[Number(find.pcke.substring(0, 1))];
codeChallenge = find.pcke.substring(2);
}
const client = await OAuth2Clients.fetchById(find.clientId as number);
return {
...find,
clientIdPub: client.client_id,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod
};
}
static async removeByCode(code: string | OAuth2Token): Promise<boolean> {
const findBy = typeof code === 'string' ? code : code.token;
const find = await OAuth2Tokens.fetchByToken(findBy, OAuth2TokenType.CODE);
await OAuth2Tokens.remove(find);
return true;
}
checkTTL(code: OAuth2Code): boolean {
return new Date(code.expires_at).getTime() > Date.now();
}
getCodeChallenge(code: OAuth2Code) {
return {
method: code.code_challenge_method,
challenge: code.code_challenge
};
}
}
export class OAuth2AccessTokens {
static async create(userId: number, clientId: string, scope: string | string[], ttl: number) {
const client = await OAuth2Clients.fetchById(clientId);
const user = await Users.getById(userId);
const accessToken = CryptoUtils.generateString(64);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
const expiresAt = new Date(Date.now() + ttl * 1000);
await OAuth2Tokens.insert(
accessToken,
OAuth2TokenType.ACCESS_TOKEN,
client,
scopes,
expiresAt,
user
);
return accessToken;
}
static async fetchByToken(token: string | OAuth2Token): Promise<OAuth2AccessToken | undefined> {
const findBy = typeof token === 'string' ? token : token.token;
const find = await OAuth2Tokens.fetchByToken(findBy, OAuth2TokenType.ACCESS_TOKEN);
if (!find) {
return undefined;
}
const client = await OAuth2Clients.fetchById(find.clientId as number);
return {
...find,
clientIdPub: client.client_id
};
}
static checkTTL(token: OAuth2AccessToken): boolean {
return new Date() < new Date(token.expires_at);
}
static getTTL(token: OAuth2AccessToken): number {
return new Date(token.expires_at).getTime() - Date.now();
}
static async fetchByUserIdClientId(
userId: number,
clientId: string
): Promise<OAuth2AccessToken | undefined> {
const find = await OAuth2Tokens.fetchByUserIdClientId(
userId,
clientId,
OAuth2TokenType.ACCESS_TOKEN
);
if (!find) {
return undefined;
}
return {
...find.o_auth2_token,
clientIdPub: find.o_auth2_client.client_id
};
}
}
export class OAuth2RefreshTokens {
static async create(userId: number, clientId: string, scope: string | string[]) {
const client = await OAuth2Clients.fetchById(clientId);
const user = await Users.getById(userId);
const accessToken = CryptoUtils.generateString(64);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');
const expiresAt = new Date(Date.now() + OAuth2Tokens.refreshTtl * 1000);
await OAuth2Tokens.insert(
accessToken,
OAuth2TokenType.REFRESH_TOKEN,
client,
scopes,
expiresAt,
user
);
return accessToken;
}
static async fetchByToken(token: string | OAuth2Token): Promise<OAuth2RefreshToken | undefined> {
const findBy = typeof token === 'string' ? token : token.token;
const find = await OAuth2Tokens.fetchByToken(findBy, OAuth2TokenType.REFRESH_TOKEN);
if (!find) {
return undefined;
}
const client = await OAuth2Clients.fetchById(find.clientId as number);
return {
...find,
clientIdPub: client.client_id
};
}
async removeByRefreshToken(token: string): Promise<boolean> {
const find = await OAuth2Tokens.fetchByToken(token, OAuth2TokenType.REFRESH_TOKEN);
await OAuth2Tokens.remove(find);
return true;
}
}

View File

@ -0,0 +1,109 @@
import {
db,
oauth2Client,
oauth2ClientAuthorization,
type OAuth2Client,
type User
} from '$lib/server/drizzle';
import { Users } from '$lib/server/users';
import { and, eq } from 'drizzle-orm';
import { OAuth2Clients } from './client';
import { OAuth2Tokens } from './tokens';
import { PUBLIC_URL } from '$env/static/public';
import { JWT } from '$lib/server/jwt';
export class OAuth2Users {
static async fetchFromLocals(locals: App.Locals) {
return Users.getBySession(locals.session.data?.user);
}
static async consented(userId: number, clientId: string, scopes: string | string[]) {
const normalized = OAuth2Clients.splitScope(scopes);
return !!(
await db
.select({
id: oauth2ClientAuthorization.id,
scope: oauth2ClientAuthorization.scope
})
.from(oauth2ClientAuthorization)
.innerJoin(oauth2Client, eq(oauth2ClientAuthorization.clientId, oauth2Client.id))
.where(
and(eq(oauth2Client.client_id, clientId), eq(oauth2ClientAuthorization.userId, userId))
)
).filter(({ scope }) => {
const splitScope = OAuth2Clients.splitScope(scope || '');
return normalized.every((item) => splitScope.includes(item));
})?.length;
}
static async saveConsent(user: User, client: OAuth2Client, scopes: string | string[]) {
const normalized = OAuth2Clients.splitScope(scopes);
const [existing] = await db
.select()
.from(oauth2ClientAuthorization)
.where(
and(
eq(oauth2ClientAuthorization.clientId, client.id),
eq(oauth2ClientAuthorization.userId, user.id)
)
)
.limit(1);
if (existing) {
const splitScope = OAuth2Clients.splitScope(existing.scope || '');
normalized.forEach((entry) => {
if (!splitScope.includes(entry)) {
splitScope.push(entry);
}
});
await db
.update(oauth2ClientAuthorization)
.set({ scope: OAuth2Clients.joinScope(splitScope) })
.where(eq(oauth2ClientAuthorization.id, existing.id));
return;
}
await db
.insert(oauth2ClientAuthorization)
.values({ userId: user.id, clientId: client.id, scope: OAuth2Clients.joinScope(normalized) });
}
static async revokeConsent(user: User, clientId: string) {
const client = await OAuth2Clients.fetchById(clientId);
if (!client) return false;
await OAuth2Tokens.wipeClientTokens(client, user);
await db
.delete(oauth2ClientAuthorization)
.where(
and(
eq(oauth2ClientAuthorization.userId, user.id),
eq(oauth2ClientAuthorization.clientId, client.id)
)
);
return true;
}
static async issueIdToken(user: User, client: OAuth2Client, scope: string[], nonce?: string) {
const userData: Record<string, unknown> = {
name: user.display_name,
preferred_username: user.username,
nickname: user.display_name,
updated_at: user.updated_at,
nonce
};
if (scope.includes('email')) {
userData.email = user.email;
userData.email_verified = true;
}
if (scope.includes('picture') && user.pictureId) {
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
}
return JWT.issue(userData, user.uuid, client.client_id);
}
}

View File

@ -0,0 +1,127 @@
import { redirect } from '@sveltejs/kit';
import { OAuth2Error } from './error';
interface ErrorResponseData {
[x: string]: string | undefined;
error: string;
error_description: string;
state?: string;
}
export interface OAuth2TokenResponse {
id_token?: string;
access_token?: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
state?: string;
}
export class OAuth2Response {
static createResponse(code: number, data: unknown) {
const isJson = typeof data === 'object';
const body = isJson ? JSON.stringify(data) : (data as string);
return new Response(body, {
status: code,
headers: {
'Content-Type': isJson ? 'application/json' : 'application/octet-stream'
}
});
}
static redirect(redirectUri: string) {
return redirect(302, redirectUri);
}
static error(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
if (redirectUri) {
const obj: ErrorResponseData = {
error: err.code,
error_description: err.message
};
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += '?' + new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return OAuth2Response.createResponse(err.status, {
error: err.code,
error_description: err.message
});
}
static errorPlain(url: URL, err: OAuth2Error, redirectUri?: string) {
if (!(err instanceof OAuth2Error)) {
throw err;
}
if (redirectUri) {
const obj: ErrorResponseData = {
error: err.code,
error_description: err.message
};
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += '?' + new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return {
error: err.code,
error_description: err.message
};
}
static response(
url: URL,
obj: OAuth2TokenResponse,
redirectUri?: string,
fragment: boolean = false
) {
if (redirectUri) {
redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&';
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return OAuth2Response.createResponse(200, obj);
}
static responsePlain(
url: URL,
obj: OAuth2TokenResponse,
redirectUri?: string,
fragment: boolean = false
) {
if (redirectUri) {
redirectUri += fragment ? '#' : redirectUri.indexOf('?') === -1 ? '?' : '&';
if (url.searchParams.has('state')) {
obj.state = url.searchParams.get('state') as string;
}
redirectUri += new URLSearchParams(obj as Record<string, string>).toString();
return redirect(302, redirectUri);
}
return obj;
}
}

View File

@ -2,8 +2,18 @@ import bcrypt from 'bcryptjs';
import { and, eq, or } from 'drizzle-orm';
import { db, user, type User } from '../drizzle';
import type { UserSession } from './types';
import { redirect } from '@sveltejs/kit';
export class Users {
static async getById(id: number): Promise<User | undefined> {
const [result] = await db
.select()
.from(user)
.where(and(eq(user.id, id), eq(user.activated, 1)))
.limit(1);
return result;
}
static async getByUuid(uuid: string): Promise<User | undefined> {
const [result] = await db
.select()
@ -52,6 +62,15 @@ export class Users {
username: user.username
};
}
static async readSessionOrRedirect(locals: App.Locals, url: URL) {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(301, `/login?redirectTo=${encodeURIComponent(url.pathname + url.search)}`);
}
return currentUser;
}
}
export * from './types';

View File

@ -27,7 +27,7 @@ export class TimeOTP {
and(
eq(userToken.type, 'totp'),
eq(userToken.userId, subject.id),
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date().toISOString()))
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date()))
)
);
return tokens?.length;
@ -41,7 +41,7 @@ export class TimeOTP {
and(
eq(userToken.type, 'totp'),
eq(userToken.userId, subject.id),
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date().toISOString()))
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date()))
)
)
.limit(1);

View File

@ -0,0 +1,40 @@
import { OAuth2Authorization } from '$lib/server/oauth2/authorization.js';
import { OAuth2Error } from '$lib/server/oauth2/error.js';
import { OAuth2Response } from '$lib/server/oauth2/response.js';
import { Users } from '$lib/server/users';
import { fail } from '@sveltejs/kit';
export const actions = {
default: async ({ request, locals, url }) => {
locals.user = await Users.readSessionOrRedirect(locals, url);
try {
return await OAuth2Authorization.actionRequest({ request, locals, url });
} catch (err) {
if (err instanceof OAuth2Error) {
const obj = OAuth2Response.errorPlain(
url,
err,
url.searchParams.get('redirect_uri') || undefined
);
return fail(err.status, obj);
}
throw err;
}
}
};
export const load = async ({ locals, url }) => {
locals.user = await Users.readSessionOrRedirect(locals, url);
try {
return await OAuth2Authorization.getRequest({ locals, url });
} catch (err) {
if (err instanceof OAuth2Error) {
return OAuth2Response.errorPlain(url, err, url.searchParams.get('redirect_uri') || undefined);
}
throw err;
}
};

View File

@ -0,0 +1,63 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import ColumnView from '$lib/components/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import { t } from '$lib/i18n';
import type { PageData } from './$types';
export let data: PageData;
</script>
<MainContainer>
<h1>{$t('common.siteName')}</h1>
{#if data.error}
<ColumnView>
<Alert type="error"
>{$t('oauth2.authorize.errorPage')}<br /><br /><code
>{data.error}: {data.error_description}</code
></Alert
>
<Button on:click={() => history?.back()} variant="link">{$t('common.back')}</Button>
</ColumnView>
{/if}
{#if data.client}
<h2>{$t('oauth2.authorize.title')}</h2>
{#if data.user}
<AvatarCard user={data.user}>
<ColumnView>
<span class="user-display-name">{data.user.name}</span>
<span class="user-user-name">@{data.user.username}</span>
</ColumnView>
</AvatarCard>
{/if}
{data.client.title}
{data.client.description}
<div class="scope-list scope-list-allowed">
{#each data.client.allowedScopes as scope}
{$t(`oauth2.authorize.scope.${scope}`)}
{/each}
</div>
<div class="scope-list scope-list-disallowed">
{#each data.client.disallowedScopes as scope}
{$t(`oauth2.authorize.scope.${scope}`)}
{/each}
</div>
<form action="" method="POST">
<input type="hidden" value="1" name="decision" />
<Button type="submit" variant="primary">{$t('oauth2.authorize.authorize')}</Button>
</form>
<form action="" method="POST">
<input type="hidden" value="0" name="decision" />
<Button type="submit" variant="link">{$t('oauth2.authorize.reject')}</Button>
</form>
{/if}
</MainContainer>