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",
|
"bcryptjs": "^2.4.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
|
"jose": "^5.3.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"mysql2": "^3.9.7",
|
"mysql2": "^3.9.7",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
@ -3751,6 +3752,14 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
|
@ -41,6 +41,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
|
"jose": "^5.3.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"mysql2": "^3.9.7",
|
"mysql2": "^3.9.7",
|
||||||
"otplib": "^12.0.1",
|
"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 { UserSession } from '$lib/server/users/types';
|
||||||
import type { Session } from 'svelte-kit-cookie-session';
|
import type { Session } from 'svelte-kit-cookie-session';
|
||||||
|
|
||||||
type SessionData = {
|
type SessionData = {
|
||||||
user?: UserSession;
|
user?: UserSession;
|
||||||
}
|
};
|
||||||
|
|
||||||
// See https://kit.svelte.dev/docs/types#app
|
// See https://kit.svelte.dev/docs/types#app
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
@ -13,6 +14,7 @@ declare global {
|
|||||||
|
|
||||||
interface Locals {
|
interface Locals {
|
||||||
session: Session<SessionData>;
|
session: Session<SessionData>;
|
||||||
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let user: { uuid: string; username: string };
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper{$$slots.default ? ' with-actions' : ''}">
|
||||||
<div class="image-wrapper">
|
<div class="image-wrapper">
|
||||||
<img src={avatarSource} alt={user.username} />
|
<img src={avatarSource} alt={user.username} />
|
||||||
</div>
|
</div>
|
||||||
@ -18,8 +18,11 @@
|
|||||||
<style>
|
<style>
|
||||||
.avatar-wrapper {
|
.avatar-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
&.with-actions {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.image-wrapper {
|
.image-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -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.",
|
"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",
|
"submit": "Submit",
|
||||||
"cancel": "Cancel",
|
"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',
|
locale: 'en',
|
||||||
key: 'account',
|
key: 'account',
|
||||||
loader: async () => await import('./en/account.json')
|
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> {
|
public static async decryptChallenge<T>(challenge: string): Promise<T> {
|
||||||
return JSON.parse(this.decrypt(challenge, CHALLENGE_SECRET));
|
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 {
|
import {
|
||||||
mysqlTable,
|
mysqlTable,
|
||||||
int,
|
int,
|
||||||
@ -19,8 +20,8 @@ export const auditLog = mysqlTable('audit_log', {
|
|||||||
actor_ip: text('actor_ip'),
|
actor_ip: text('actor_ip'),
|
||||||
actor_ua: text('actor_ua'),
|
actor_ua: text('actor_ua'),
|
||||||
flagged: tinyint('flagged').default(0).notNull(),
|
flagged: tinyint('flagged').default(0).notNull(),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
|
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
|
||||||
});
|
});
|
||||||
@ -31,11 +32,11 @@ export const document = mysqlTable('document', {
|
|||||||
slug: text('slug').notNull(),
|
slug: text('slug').notNull(),
|
||||||
body: text('body').notNull(),
|
body: text('body').notNull(),
|
||||||
authorId: int('authorId').references(() => user.id),
|
authorId: int('authorId').references(() => user.id),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,11 +54,11 @@ export const oauth2Client = mysqlTable(
|
|||||||
verified: tinyint('verified').default(0).notNull(),
|
verified: tinyint('verified').default(0).notNull(),
|
||||||
pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }),
|
pictureId: int('pictureId').references(() => upload.id, { onDelete: 'set null' }),
|
||||||
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
|
ownerId: int('ownerId').references(() => user.id, { onDelete: 'set null' }),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.notNull()
|
||||||
},
|
},
|
||||||
(table) => {
|
(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', {
|
export const oauth2ClientAuthorization = mysqlTable('o_auth2_client_authorization', {
|
||||||
id: int('id').autoincrement().notNull(),
|
id: int('id').autoincrement().notNull(),
|
||||||
scope: text('scope'),
|
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' }),
|
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
||||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type OAuth2ClientAuthorization = typeof oauth2ClientAuthorization.$inferSelect;
|
||||||
|
export type NewOAuth2ClientAuthorization = typeof oauth2ClientAuthorization.$inferInsert;
|
||||||
|
|
||||||
export const oauth2ClientUrl = mysqlTable('o_auth2_client_url', {
|
export const oauth2ClientUrl = mysqlTable('o_auth2_client_url', {
|
||||||
id: int('id').autoincrement().notNull(),
|
id: int('id').autoincrement().notNull(),
|
||||||
url: varchar('url', { length: 255 }).notNull(),
|
url: varchar('url', { length: 255 }).notNull(),
|
||||||
type: mysqlEnum('type', ['redirect_uri', 'terms', 'privacy', 'website']).notNull(),
|
type: mysqlEnum('type', ['redirect_uri', 'terms', 'privacy', 'website']).notNull(),
|
||||||
created_at: timestamp('created_at', { fsp: 6, mode: 'string' })
|
created_at: timestamp('created_at', { fsp: 6, mode: 'date' })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: timestamp('updated_at', { fsp: 6, mode: 'string' })
|
updated_at: timestamp('updated_at', { fsp: 6, mode: 'date' })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' })
|
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', {
|
export const oauth2Token = mysqlTable('o_auth2_token', {
|
||||||
id: int('id').autoincrement().notNull(),
|
id: int('id').autoincrement().notNull(),
|
||||||
type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(),
|
type: mysqlEnum('type', ['code', 'access_token', 'refresh_token']).notNull(),
|
||||||
token: text('token').notNull(),
|
token: text('token').notNull(),
|
||||||
scope: text('scope'),
|
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' }),
|
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||||
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
clientId: int('clientId').references(() => oauth2Client.id, { onDelete: 'cascade' }),
|
||||||
nonce: text('nonce'),
|
nonce: text('nonce'),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
pcke: text('pcke')
|
pcke: text('pcke')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type OAuth2Token = typeof oauth2Token.$inferSelect;
|
||||||
|
export type NewOAuth2Token = typeof oauth2Token.$inferInsert;
|
||||||
|
|
||||||
export const privilege = mysqlTable('privilege', {
|
export const privilege = mysqlTable('privilege', {
|
||||||
id: int('id').autoincrement().notNull(),
|
id: int('id').autoincrement().notNull(),
|
||||||
name: text('name').notNull()
|
name: text('name').notNull()
|
||||||
@ -123,11 +140,11 @@ export const upload = mysqlTable('upload', {
|
|||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
onUpdate: 'cascade'
|
onUpdate: 'cascade'
|
||||||
}),
|
}),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -143,18 +160,18 @@ export const user = mysqlTable(
|
|||||||
display_name: varchar('display_name', { length: 32 }).notNull(),
|
display_name: varchar('display_name', { length: 32 }).notNull(),
|
||||||
password: text('password'),
|
password: text('password'),
|
||||||
activated: tinyint('activated').default(0).notNull(),
|
activated: tinyint('activated').default(0).notNull(),
|
||||||
activity_at: timestamp('activity_at', { mode: 'string' })
|
activity_at: timestamp('activity_at', { mode: 'date' })
|
||||||
.default('current_timestamp()')
|
.default(sql`current_timestamp()`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
pictureId: int('pictureId').references((): AnyMySqlColumn => upload.id, {
|
pictureId: int('pictureId').references((): AnyMySqlColumn => upload.id, {
|
||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
onUpdate: 'cascade'
|
onUpdate: 'cascade'
|
||||||
}),
|
}),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
updated_at: datetime('updated_at', { mode: 'string', fsp: 6 })
|
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.notNull()
|
||||||
},
|
},
|
||||||
(table) => {
|
(table) => {
|
||||||
@ -201,10 +218,10 @@ export const userToken = mysqlTable('user_token', {
|
|||||||
'public_key',
|
'public_key',
|
||||||
'recovery'
|
'recovery'
|
||||||
]).notNull(),
|
]).notNull(),
|
||||||
expires_at: timestamp('expires_at', { mode: 'string' }),
|
expires_at: timestamp('expires_at', { mode: 'date' }),
|
||||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||||
nonce: text('nonce'),
|
nonce: text('nonce'),
|
||||||
created_at: datetime('created_at', { mode: 'string', fsp: 6 })
|
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||||
.default('current_timestamp(6)')
|
.default(sql`current_timestamp(6)`)
|
||||||
.notNull()
|
.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 { and, eq, or } from 'drizzle-orm';
|
||||||
import { db, user, type User } from '../drizzle';
|
import { db, user, type User } from '../drizzle';
|
||||||
import type { UserSession } from './types';
|
import type { UserSession } from './types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
export class Users {
|
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> {
|
static async getByUuid(uuid: string): Promise<User | undefined> {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
.select()
|
.select()
|
||||||
@ -52,6 +62,15 @@ export class Users {
|
|||||||
username: user.username
|
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';
|
export * from './types';
|
||||||
|
@ -27,7 +27,7 @@ export class TimeOTP {
|
|||||||
and(
|
and(
|
||||||
eq(userToken.type, 'totp'),
|
eq(userToken.type, 'totp'),
|
||||||
eq(userToken.userId, subject.id),
|
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;
|
return tokens?.length;
|
||||||
@ -41,7 +41,7 @@ export class TimeOTP {
|
|||||||
and(
|
and(
|
||||||
eq(userToken.type, 'totp'),
|
eq(userToken.type, 'totp'),
|
||||||
eq(userToken.userId, subject.id),
|
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);
|
.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