diff --git a/docker-compose.yml b/docker-compose.yml index 9a130f0..d77ccd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,6 +65,7 @@ services: environment: - JWT_SECRET=KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JA - DATABASE_DIR=/volumes/database + - PROVIDER_FILE=/extra/providers.toml ############### # USER # diff --git a/src/auth/package.json b/src/auth/package.json index 01ccdd9..4b64a1a 100644 --- a/src/auth/package.json +++ b/src/auth/package.json @@ -23,6 +23,7 @@ "@fastify/sensible": "^6.0.0", "@fastify/static": "^8.2.0", "@sinclair/typebox": "^0.34.40", + "confbox": "^0.2.2", "fastify": "^5.0.0", "fastify-cli": "^7.4.0", "fastify-plugin": "^5.0.0" diff --git a/src/auth/src/oauth2.ts b/src/auth/src/oauth2.ts new file mode 100644 index 0000000..fc64c18 --- /dev/null +++ b/src/auth/src/oauth2.ts @@ -0,0 +1,383 @@ +import { isNullish } from '@shared/utils'; +import type { Provider, ProviderSecret, ProviderUserInfo } from './plugins/providers'; +import Type, { Static } from '@sinclair/typebox'; +import Value from '@sinclair/typebox/value'; +import { createHash, randomBytes } from 'node:crypto'; + +const OpenIdManifest = Type.Object( + { + authorization_endpoint: Type.String(), + token_endpoint: Type.String(), + userinfo_endpoint: Type.String(), + }, + { additionalProperties: true }, +); + +type OpenIdManifest = Static; + +function removePadding(s: string): string { + return s.replace(/=+$/, ''); +} + +export class CsrfToken { + private constructor(private readonly secret_: string) { } + + public static newRandom(length: number = 16) { + if (!Number.isInteger(length)) throw 'length must be an integer'; + if (length < 8) throw 'length must be >= 8'; + const bytes = randomBytes(length); + return new CsrfToken(removePadding(bytes.toString('base64url'))); + } + + public secret(): string { + return this.secret_; + } +} + +export class Nonce { + private constructor(private readonly value_: string) { } + + public static newRandom(length: number = 16) { + if (!Number.isInteger(length)) throw 'length must be an integer'; + if (length < 8) throw 'length must be >= 8'; + const bytes = randomBytes(length); + return new Nonce(removePadding(bytes.toString('base64url'))); + } + + public value(): string { + return this.value_; + } +} + +export type PkceMethod = 'S256'; + +export class PkceVerifier { + public readonly secret: string; + public readonly method: PkceMethod; + + public constructor(secret: string, method: PkceMethod) { + this.secret = secret; + this.method = method; + } + + public static new(size: number = 32, method: PkceMethod) { + if (!Number.isInteger(size)) throw 'size must be an integer'; + if (size < 32 || size > 96) { + throw 'size must be between 32 and 96 (inclusive)'; + } + + const bytes = randomBytes(size); + return new PkceVerifier( + removePadding(bytes.toString('base64url')), + method, + ); + } +} + +export class PkceChallenge { + public readonly challenge: string; + public readonly method: PkceMethod; + + private constructor(challenge: string, method: PkceMethod) { + this.challenge = challenge; + this.method = method; + } + + private static fromVerifier(verifier: PkceVerifier): PkceChallenge { + switch (verifier.method) { + case 'S256': { + const digest = removePadding( + createHash('sha256') + .update(verifier.secret) + .digest('base64url'), + ); + + return new PkceChallenge(digest, verifier.method); + } + default: + throw `Unknown PkceMethod '${verifier.method}'`; + } + } + + public static new( + length: number = 32, + method: PkceMethod = 'S256', + ): [PkceChallenge, PkceVerifier] { + const verifier = PkceVerifier.new(length, method); + + return [PkceChallenge.fromVerifier(verifier), verifier]; + } +} + +export class AuthorizationUrl { + private scopes_: Set; + private pkce_challenge?: PkceChallenge; + private csrf_token: CsrfToken; + private nonce: Nonce; + + constructor( + public readonly auth_url: URL, + public readonly redirect_url: URL, + public readonly client_id: string, + csrf_token: () => CsrfToken, + nonce: () => Nonce, + public readonly additional: [string, string][], + ) { + this.scopes_ = new Set(); + this.csrf_token = csrf_token(); + this.nonce = nonce(); + } + + get scopes(): readonly string[] { + return Array.from(this.scopes_.values()); + } + + public addScope(scope: string) { + this.scopes_.add(scope); + } + + public setPkceChallenge(challenge: PkceChallenge) { + this.pkce_challenge = challenge; + } + + public intoUrl(): [URL, CsrfToken, Nonce] { + const scopes = this.scopes_.values().toArray().join(' '); + + const url = (() => { + const u = this.auth_url; + const pairs: [string, string][] = [ + ['response_type', 'code'], + ['client_id', this.client_id], + ['state', this.csrf_token.secret()], + ['nonce', this.nonce.value()], + ]; + if (!isNullish(this.pkce_challenge)) { + pairs.push(['code_challenge', this.pkce_challenge.challenge]); + pairs.push([ + 'code_challenge_method', + this.pkce_challenge.method, + ]); + } + if (!isNullish(this.redirect_url)) { + pairs.push(['redirect_uri', this.redirect_url.toString()]); + } + + if (scopes.length !== 0) pairs.push(['scope', scopes]); + this.additional.forEach((p) => pairs.push(p)); + + for (const [k, v] of pairs) u.searchParams.set(k, v); + return u; + })(); + + return [url, this.csrf_token, this.nonce]; + } +} + +export class AuthorizationCode { + public constructor(private readonly code: string) { } + + public secret(): string { + return this.code; + } +} +export class CodeTokenRequest { + private pkce_verifier?: PkceVerifier; + + public constructor( + private readonly client_id: string, + private readonly client_secret: string, + private readonly token_url: URL, + private readonly redirect_url: URL, + private readonly code: AuthorizationCode, + ) { } + + public setPkceVerifier(val: PkceVerifier) { + this.pkce_verifier = val; + } + + public async getCode(): Promise { + const params: [string, string][] = [ + ['grant_type', 'authorization_code'], + ['code', this.code.secret()], + ['redirect_uri', this.redirect_url.toString()], + ['client_id', this.client_id], + ['client_secret', this.client_secret], + ]; + + if (!isNullish(this.pkce_verifier)) { + params.push(['code_verifier', this.pkce_verifier.secret]); + } + const req = await fetch(this.token_url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + Authorization: `BASIC ${removePadding(Buffer.from(`${encodeURIComponent(this.client_id)}:${encodeURIComponent(this.client_secret)}`).toString('base64url'))}`, + 'User-Agent': 'ft_transcendance (maix.me, 1)', + }, + body: (() => { + return new URLSearchParams(params); + })(), + }); + if (!req.ok) { + throw `failed to fetch code from oauth2 provider: ${req.status} - ${req.statusText} body: ${await req.text()}`; + } + const body = await req.json(); + if ( + typeof body !== 'object' || + isNullish(body) || + !('access_token' in body) + ) { + throw `response doesn't have an access body: ${JSON.stringify(body)}`; + } + + return body.access_token as string; + } +} + +export class Oauth2 { + public readonly auth_url: URL; + public readonly token_url: URL; + public readonly info_url: URL; + public readonly redirect_url: URL; + + private constructor( + auth_url: string, + token_url: string, + info_url: string, + redirect_url: string, + + public readonly client_id: string, + private readonly client_secret: string, + public readonly display_name: string, + public readonly scopes: string[], + public readonly keys: { name: string, unique_id: string }, + ) { + this.auth_url = new URL(auth_url); + this.token_url = new URL(token_url); + this.info_url = new URL(info_url); + this.redirect_url = new URL(redirect_url); + } + + private static handleClientSecret(secret: ProviderSecret): string { + if ('env' in secret) { + const value = process.env[secret.env]; + if (isNullish(value)) { + throw `'${secret.env}' not present in environment variables`; + } + return value; + } + else if ('inline' in secret) { + return secret.inline; + } + else { + throw 'invalid provider secret: not either env|inner in secret'; + } + } + + public exchangeCode(code: AuthorizationCode): CodeTokenRequest { + return new CodeTokenRequest( + this.client_id, + this.client_secret, + this.token_url, + this.redirect_url, + code, + ); + } + + public authorize_url( + csrf_token: () => CsrfToken, + nonce: () => Nonce, + extra_pairs?: [string, string][], + ): AuthorizationUrl { + if (isNullish(extra_pairs)) { + extra_pairs = []; + } + const u = new AuthorizationUrl( + this.auth_url, + this.redirect_url, + this.client_id, + csrf_token, + nonce, + extra_pairs, + ); + this.scopes.forEach((scope) => u.addScope(scope)); + return u; + } + + static async fromProvider( + display_name: string, + provider: Provider, + ): Promise { + try { + const secret = Oauth2.handleClientSecret(provider.client_secret); + const id = provider.client_id; + if ('openid_url' in provider) { + const req = await fetch( + `${provider.openid_url}/.well-known/openid-configuration`, + ); + const j = await req.json(); + const v = Value.Parse(OpenIdManifest, j); + if (!('openid' in provider.scopes)) { + provider.scopes.push('openid'); + } + + return new Oauth2( + v.authorization_endpoint, + v.token_endpoint, + v.userinfo_endpoint, + provider.redirect_url, + id, + secret, + display_name, + provider.scopes, + provider.user, + ); + } + else if ('token_url' in provider) { + return new Oauth2( + provider.auth_url, + provider.token_url, + provider.info_url, + provider.redirect_url, + id, + secret, + display_name, + provider.scopes, + provider.user, + ); + } + throw 'unknown provider type'; + } + catch (e) { + throw `provider "${display_name}": ${e}`; + } + } + + + public async getUserInfo(token: string): Promise { + const req = await fetch(this.info_url, { + method: 'GET', headers: { + 'Accept': 'application/json', + 'Authorization': `Bearer ${token}`, + 'User-Agent': 'ft_transcendance (maix.me, 1)', + }, + }); + if (!req.ok) { + throw `failed to fetch userinfo from oauth2 provider: ${req.status} - ${req.statusText} body: ${await req.text()}`; + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + /* we know that it is a json object, just we don't know the form of it. */ + /* the next line will allow us to make sure that it is an object, with required field and that they are strings */ + const json: any = await req.json(); + + if (typeof json !== 'object' || isNullish(json)) { throw 'failed to fetch userinfo from oauth2 provider: not a json object'; } + for (const [k, v] of Object.entries(this.keys)) { + if (!(v in json) || (typeof json[v] !== 'string')) { throw `failed to fetch userinfo from oauth2 provider: '${v}'(${k} key) not present in response`; } + } + + return Object.fromEntries(Object.entries(this.keys).map(([k, v]) => [k, json[v] as string])) as ProviderUserInfo; + } +} + diff --git a/src/auth/src/plugins/providers.ts b/src/auth/src/plugins/providers.ts new file mode 100644 index 0000000..a190b8f --- /dev/null +++ b/src/auth/src/plugins/providers.ts @@ -0,0 +1,87 @@ +import { isNullish } from '@shared/utils'; +import fp from 'fastify-plugin'; +import { readFile } from 'node:fs/promises'; +import * as T from '@sinclair/typebox'; +import * as V from '@sinclair/typebox/value'; +import { Oauth2 } from '../oauth2'; +import { parseTOML } from 'confbox'; + +/* +function isNullish(_v: T): boolean { return true; } +class Oauth2 { + constructor(..._args: any[]) { } + static fromProvider(..._args: any[]): Oauth2 { throw 'yes'; } +} +*/ + +const ProviderSecret = T.Union([ + T.Object({ + env: T.String({ description: 'Secret is stored in the env var' }), + }), + T.Object({ inline: T.String({ description: 'Secret is inline here' }) }), +]); + +const ProviderUserInfo = T.Object({ + unique_id: T.String({ description: 'A unique identifier for this provider', default: 'email' }), + name: T.String({ description: 'A name for this provider', default: 'name' }), +}, { default: { unique_id: 'email', name: 'name' } }); + +const RawProviderBase = { + client_id: T.String(), + client_secret: ProviderSecret, + scopes: T.Array(T.String()), + redirect_url: T.String(), + user: ProviderUserInfo, +}; + +const ProviderBase = T.Object(RawProviderBase); +const ProviderOauth2 = T.Object({ + token_url: T.String(), + auth_url: T.String(), + info_url: T.String(), + ...RawProviderBase, +}); +const ProviderOpenId = T.Object({ openid_url: T.String(), ...RawProviderBase }); +const Provider = T.Union([ProviderOauth2, ProviderOpenId]); +const ProviderMap = T.Record(T.String(), Provider); +const ProviderMapFile = T.Object({ + providers: ProviderMap, + $schema: T.Optional(T.String()), +}); + +export type ProviderSecret = T.Static; +export type ProviderUserInfo = T.Static; +export type ProviderBase = T.Static; +export type ProviderOauth2 = T.Static; +export type ProviderOpenId = T.Static; +export type Provider = T.Static; +export type ProviderMap = T.Static; + +export type ProviderMapFile = T.Static; + +async function buildProviderMap(): Promise { + const providerFile = process.env.PROVIDER_FILE; + if (isNullish(providerFile)) throw 'PROVIDER_FILE env var not provided'; + const data = await readFile(providerFile, { encoding: 'utf-8' }); + const dataJson = parseTOML(data); + return V.Parse(ProviderMapFile, dataJson).providers; +} + +declare module 'fastify' { + export interface FastifyInstance { + providers: ProviderMap; + oauth2: { [k: string]: Oauth2 }; + } +} +async function makeAllOauth2(providers: ProviderMap): Promise<{ [k: string]: Oauth2 }> { + const out: { [k: string]: Oauth2 } = {}; + for (const [k, v] of Object.entries(providers)) { + out[k] = await Oauth2.fromProvider(k, v); + } + return out; +} + +export default fp(async (fastify) => { + fastify.decorate('providers', await buildProviderMap()); + fastify.decorate('oauth2', await makeAllOauth2(fastify.providers)); +}); diff --git a/src/auth/src/routes/oauth2/callback.ts b/src/auth/src/routes/oauth2/callback.ts new file mode 100644 index 0000000..00575fa --- /dev/null +++ b/src/auth/src/routes/oauth2/callback.ts @@ -0,0 +1,47 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { Static, Type } from '@sinclair/typebox'; +import { typeResponse, isNullish } from '@shared/utils'; +import * as oauth2 from '../../oauth2'; + + +export const WhoAmIRes = Type.Union([ + typeResponse('success', 'disableOtp.success'), + typeResponse('failure', 'disableOtp.failure.generic'), +]); + +export type WhoAmIRes = Static; + +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { + void _opts; + fastify.get<{ Params: { provider?: string } }>( + '/api/auth/oauth2/:provider/callback', + async function(req, res) { + const qs = (req.query as { [k: string]: string }); + if (isNullish(req.params.provider) || !(req.params.provider in this.oauth2)) { return `provider '${req.params.provider ?? 'none'}' doesn't exists`; } + const provider = this.oauth2[req.params.provider]; + if (!('code' in qs)) { return res.code(400).send('no code in querystring...'); } + if (!('pkce' in req.cookies) && isNullish(req.cookies.pkce)) { return res.code(400).send('no pkce cookies'); } + const code = new oauth2.AuthorizationCode(qs.code); + const pkce = new oauth2.PkceVerifier(req.cookies.pkce!, 'S256'); + const creq = provider.exchangeCode(code); + creq.setPkceVerifier(pkce); + const result = await creq.getCode(); + + const userinfo = await provider.getUserInfo(result); + let u = this.db.getUserFromProviderUser(provider.display_name, userinfo.unique_id); + if (isNullish(u)) { + u = await this.db.createUserWithProvider(provider.display_name, userinfo.unique_id, userinfo.name); + } + if (isNullish(u)) { + return res.code(500).send('failed to fetch or create user...'); + } + const token = this.signJwt('auth', u.id); + + + return res.setCookie('token', token, { path: '/' }).redirect('/'); + }, + ); +}; + +export default route; diff --git a/src/auth/src/routes/oauth2/login.ts b/src/auth/src/routes/oauth2/login.ts new file mode 100644 index 0000000..0c41f3d --- /dev/null +++ b/src/auth/src/routes/oauth2/login.ts @@ -0,0 +1,25 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { isNullish } from '@shared/utils'; +import * as oauth2 from '../../oauth2'; + +const route: FastifyPluginAsync = async (fastify, _opts): Promise => { + void _opts; + fastify.get<{ Params: { provider?: string } }>( + '/api/auth/oauth2/:provider/login', + async function(req, res) { + if (isNullish(req.params.provider) || !(req.params.provider in this.oauth2)) { return `provider '${req.params.provider ?? 'none'}' doesn't exists`; } + const provider = this.oauth2[req.params.provider]; + const [challenge, verifier] = oauth2.PkceChallenge.new(); + + const u = provider.authorize_url(oauth2.CsrfToken.newRandom, oauth2.Nonce.newRandom); + u.setPkceChallenge(challenge); + + const [url, _csrf, _nonce] = u.intoUrl(); + void _csrf; void _nonce; + return res.setCookie('pkce', verifier.secret).redirect(url.toString()); + }, + ); +}; + +export default route;