-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
658 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { type InferOutput, bigint, maxLength, minLength, object, pipe, string, transform, unknown, url } from 'valibot'; | ||
|
||
export const User = object({ | ||
student_number: bigint(), | ||
is_admin: pipe(unknown(), transform(Boolean)), | ||
is_faculty: pipe(unknown(), transform(Boolean)), | ||
email: string(), | ||
user_id: pipe(string(), minLength(1), maxLength(255)), | ||
given_name: string(), | ||
family_name: string(), | ||
avatar: pipe(string(), url()), | ||
}); | ||
|
||
export type User = InferOutput<typeof User>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { type Loggable, timed } from '$lib/decorators'; | ||
import { Pending, Session } from '$lib/server/models/session'; | ||
import { parse, pick } from 'valibot'; | ||
import type { Logger } from 'pino'; | ||
import type { User } from '$lib/models/user'; | ||
import assert from 'node:assert/strict'; | ||
import { env } from '$env/dynamic/private'; | ||
import postgres from 'postgres'; | ||
|
||
const sql = postgres(env.POSTGRES_URL, { ssl: 'prefer' }); | ||
process.once('sveltekit:shutdown', () => sql.end()); | ||
|
||
const DeletedSession = pick(Session, ['user_id', 'expiration']); | ||
|
||
export class Database implements Loggable { | ||
#logger: Logger; | ||
|
||
constructor(logger: Logger) { | ||
this.#logger = logger; | ||
} | ||
|
||
get logger() { | ||
return this.#logger; | ||
} | ||
|
||
// eslint-disable-next-line class-methods-use-this | ||
@timed async generatePendingSession() { | ||
const [first, ...rest] = await sql`INSERT INTO pendings DEFAULT VALUES RETURNING session_id`; | ||
assert(rest.length === 0); | ||
return parse(Pending, first); | ||
} | ||
|
||
// eslint-disable-next-line class-methods-use-this | ||
@timed async upgradePendingSession( | ||
sid: Pending['session_id'], | ||
uid: Session['user_id'], | ||
expiration: Session['expiration'], | ||
) { | ||
const [first, ...rest] = | ||
await sql`INSERT INTO sessions (session_id, user_id, expiration) DELETE FROM pendings WHERE session_id = ${sid} RETURNING session_id, ${uid}, ${expiration}`; | ||
assert(rest.length === 0); | ||
return typeof first === 'undefined' ? null : parse(Pending, first); | ||
} | ||
|
||
// eslint-disable-next-line class-methods-use-this | ||
@timed async upsertOpenIdUser( | ||
uid: User['user_id'], | ||
email: User['email'], | ||
given: User['given_name'], | ||
family: User['family_name'], | ||
avatar: User['avatar'], | ||
) { | ||
const { count } = | ||
await sql`INSERT INTO users (user_id, email, given_name, family_name, avatar) VALUES (${uid}, ${email}, ${given}, ${family}, ${avatar}) ON CONFLICT (user_id) DO UPDATE SET email = ${email}, given_name = ${given}, family_name = ${family}, avatar = ${avatar}`; | ||
return count; | ||
} | ||
|
||
// eslint-disable-next-line class-methods-use-this | ||
@timed async deleteSession(sid: Session['session_id']) { | ||
const [first, ...rest] = | ||
await sql`DELETE FROM sessions WHERE session_id = ${sid} RETURNING session_id, expiration`; | ||
assert(rest.length === 0); | ||
return typeof first === 'undefined' ? null : parse(DeletedSession, first); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import { | ||
boolean, | ||
email, | ||
everyItem, | ||
literal, | ||
maxLength, | ||
minLength, | ||
number, | ||
object, | ||
pipe, | ||
safeInteger, | ||
string, | ||
transform, | ||
url, | ||
} from 'valibot'; | ||
import { User } from '$lib/models/user'; | ||
|
||
const OAUTH_SCOPES = [ | ||
'openid', | ||
'https://www.googleapis.com/auth/userinfo.profile', | ||
'https://www.googleapis.com/auth/userinfo.email', | ||
]; | ||
export const OAUTH_SCOPE_STRING = OAUTH_SCOPES.join(' '); | ||
export const OAUTH_TOKEN_TYPE = 'Bearer'; | ||
|
||
/** @see https://developers.google.com/identity/protocols/oauth2#size */ | ||
export const AuthorizationCode = pipe(string(), minLength(1), maxLength(256)); | ||
|
||
export const TokenResponseSchema = object({ | ||
// JSON Web Token token containing the user's ID token. | ||
id_token: string(), | ||
// Always set to `OAUTH_SCOPE` for now. | ||
scope: pipe( | ||
string(), | ||
transform(str => str.split(' ')), | ||
everyItem(item => OAUTH_SCOPES.includes(item)), | ||
), | ||
token_type: literal(OAUTH_TOKEN_TYPE), | ||
// Remaining lifetime in seconds. | ||
expires_in: pipe(number(), safeInteger()), | ||
}); | ||
|
||
const UnixTimeSecs = pipe( | ||
number(), | ||
safeInteger(), | ||
transform(secs => new Date(secs * 1000)), | ||
); | ||
|
||
export const IdTokenSchema = object({ | ||
// OpenID audience. | ||
aud: string(), | ||
// OpenID subject. Typically the globally unique Google user ID. | ||
sub: User.entries.user_id, | ||
// Creation time (in seconds). | ||
iat: UnixTimeSecs, | ||
// Expiration time (in seconds) on or after which the token is invalid. | ||
exp: UnixTimeSecs, | ||
// OpenID issuer. | ||
iss: string(), | ||
// OpenID authorized presenter. | ||
azp: string(), | ||
// Access token hash. | ||
at_hash: string(), | ||
email: pipe(string(), email()), | ||
email_verified: boolean(), | ||
name: string(), | ||
nonce: string(), | ||
picture: pipe(string(), url()), | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import { type InferOutput, object, pipe, string, url } from 'valibot'; | ||
|
||
const DiscoveryDocument = object({ | ||
issuer: pipe(string(), url()), | ||
authorization_endpoint: pipe(string(), url()), | ||
token_endpoint: pipe(string(), url()), | ||
userinfo_endpoint: pipe(string(), url()), | ||
revocation_endpoint: pipe(string(), url()), | ||
jwks_uri: pipe(string(), url()), | ||
}); | ||
|
||
export type DiscoveryDocument = InferOutput<typeof DiscoveryDocument>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import { type InferOutput, instance, number, object, pipe, string, transform, union, uuid } from 'valibot'; | ||
import { User } from '$lib/models/user'; | ||
|
||
const CommonSchema = object({ | ||
session_id: pipe(string(), uuid()), | ||
expiration: pipe( | ||
union([number(), string()]), | ||
transform(input => new Date(input)), | ||
), | ||
}); | ||
|
||
export const Pending = object({ ...CommonSchema.entries, nonce: instance(Uint8Array) }); | ||
export type Pending = InferOutput<typeof Pending>; | ||
|
||
export const Session = object({ ...CommonSchema.entries, user_id: User.entries.user_id }); | ||
export type Session = InferOutput<typeof Session>; |