beginning authorize
This commit is contained in:
parent
4a07389cca
commit
6222b7ba18
9
package-lock.json
generated
9
package-lock.json
generated
@ -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",
|
||||
|
@ -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
2
private/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
4
src/app.d.ts
vendored
4
src/app.d.ts
vendored
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -4,5 +4,7 @@
|
||||
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is <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"
|
||||
}
|
||||
|
16
src/lib/i18n/en/oauth2.json
Normal file
16
src/lib/i18n/en/oauth2.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -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(/=+$/, '');
|
||||
}
|
||||
}
|
||||
|
@ -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
42
src/lib/server/jwt.ts
Normal 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
1
src/lib/server/oauth2/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
impl.reference/
|
272
src/lib/server/oauth2/authorization.ts
Normal file
272
src/lib/server/oauth2/authorization.ts
Normal 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);
|
||||
};
|
||||
}
|
103
src/lib/server/oauth2/error.ts
Normal file
103
src/lib/server/oauth2/error.ts
Normal 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);
|
||||
}
|
||||
}
|
1
src/lib/server/oauth2/index.ts
Normal file
1
src/lib/server/oauth2/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export class OAuth2 {}
|
142
src/lib/server/oauth2/model/client.ts
Normal file
142
src/lib/server/oauth2/model/client.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
2
src/lib/server/oauth2/model/index.ts
Normal file
2
src/lib/server/oauth2/model/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './client';
|
||||
export * from './tokens';
|
296
src/lib/server/oauth2/model/tokens.ts
Normal file
296
src/lib/server/oauth2/model/tokens.ts
Normal 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;
|
||||
}
|
||||
}
|
109
src/lib/server/oauth2/model/user.ts
Normal file
109
src/lib/server/oauth2/model/user.ts
Normal 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);
|
||||
}
|
||||
}
|
127
src/lib/server/oauth2/response.ts
Normal file
127
src/lib/server/oauth2/response.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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);
|
||||
|
40
src/routes/oauth2/authorize/+page.server.ts
Normal file
40
src/routes/oauth2/authorize/+page.server.ts
Normal 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;
|
||||
}
|
||||
};
|
63
src/routes/oauth2/authorize/+page.svelte
Normal file
63
src/routes/oauth2/authorize/+page.svelte
Normal 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>
|
Loading…
Reference in New Issue
Block a user