From b38f5841e3304ce91b1ed2494797b55d996a1712 Mon Sep 17 00:00:00 2001 From: Kakious Date: Thu, 18 Jul 2024 22:19:14 -0400 Subject: [PATCH] feat: implement oidc-core base from private project --- package.json | 1 + pnpm-lock.yaml | 8 + src/app.const.ts | 3 +- src/auth/model/user.model.ts | 12 - src/auth/oidc/adapter.ts | 652 ++++++++++++++++++ src/auth/oidc/core.service.ts | 277 +++++++- .../models/oidc_refresh_token.model.ts | 138 ++++ src/database/models/oidc_session.model.ts | 84 +++ 8 files changed, 1160 insertions(+), 15 deletions(-) delete mode 100644 src/auth/model/user.model.ts create mode 100644 src/database/models/oidc_refresh_token.model.ts create mode 100644 src/database/models/oidc_session.model.ts diff --git a/package.json b/package.json index c00edc3..bda8119 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "nestjs-otel": "^6.1.1", "nestjs-postal-client": "^0.0.6", "oidc-provider": "^8.5.1", + "psl": "^1.9.0", "pug": "^3.0.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c509ea1..4c76703 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: oidc-provider: specifier: ^8.5.1 version: 8.5.1 + psl: + specifier: ^1.9.0 + version: 1.9.0 pug: specifier: ^3.0.3 version: 3.0.3 @@ -3188,6 +3191,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + pug-attrs@3.0.0: resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} @@ -7597,6 +7603,8 @@ snapshots: proxy-from-env@1.1.0: {} + psl@1.9.0: {} + pug-attrs@3.0.0: dependencies: constantinople: 4.0.1 diff --git a/src/app.const.ts b/src/app.const.ts index 6787ab2..10afdf9 100644 --- a/src/app.const.ts +++ b/src/app.const.ts @@ -1,2 +1,3 @@ // This is the internal client ID for the application itself. -export const internalClientId = 'internal.management'; +export const internalClientId = 'system.internal.management'; +export const internalRedirectTag = 'system.internalRedirect.management'; diff --git a/src/auth/model/user.model.ts b/src/auth/model/user.model.ts deleted file mode 100644 index 78ed61d..0000000 --- a/src/auth/model/user.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsEmail } from 'class-validator'; -import { Column, Model, Table } from 'sequelize-typescript'; - -@Table -export class User extends Model { - @Column - username: string; - - @Column - @IsEmail() - email: string; -} diff --git a/src/auth/oidc/adapter.ts b/src/auth/oidc/adapter.ts index e69de29..dc6af2e 100644 --- a/src/auth/oidc/adapter.ts +++ b/src/auth/oidc/adapter.ts @@ -0,0 +1,652 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Adapter, AdapterPayload } from 'oidc-provider'; +import { DataSource } from 'typeorm'; + +import { OidcClient } from '../../database/models/oidc_client.model'; +import { OidcGrant } from '../../database/models/oidc_grant.model'; +import { OidcRefreshToken } from '../../database/models/oidc_refresh_token.model'; +import { OidcSession } from '../../database/models/oidc_session.model'; +import { RedisService } from '../../redis/redis.service'; + +const TCLIENT = 7; +const TGRANT = 13; +const TREFRESH = 4; +const TSESSION = 1; + +const types = [ + 'Session', //1 + 'AccessToken', //2 + 'AuthorizationCode', //3 + 'RefreshToken', //4 + 'DeviceCode', //5 + 'ClientCredentials', //6 + 'Client', //7 + 'InitialAccessToken', //8 + 'RegistrationAccessToken', //9 + 'Interaction', //10 + 'ReplayDetection', //11 + 'PushedAuthorizationRequest', //12 + 'Grant', //13 +].reduce((map, name, i) => ({ ...map, [name]: i + 1 }), {}); + +const grantable = new Set([ + 'AccessToken', + 'AuthorizationCode', + 'RefreshToken', + 'DeviceCode', + 'BackchannelAuthenticationRequest', +]); + +// Cache for 30 min +const globalCacheTTL = 1800; +const ClientNotSupportedError = new Error('Clients are not supported'); + +export const createOidcAdapter: ( + db: DataSource, + redis: RedisService, + baseUrl: string, +) => any = (db, redis, baseUrl) => { + const oidcClientRepo = db.getRepository(OidcClient); + const oidcGrantRepo = db.getRepository(OidcGrant); + const oidcSessionRepo = db.getRepository(OidcSession); + const oidcRefreshRepo = db.getRepository(OidcRefreshToken); + + return class SQLAdapter implements Adapter { + type: number; + + constructor(public name: string) { + this.type = types[name as keyof typeof types]; + this.name = name; + } + /** + * Update or Create an instance of an oidc-provider model. + * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when + * encountered. + * @param {string} id Identifier that oidc-provider will use to reference this model instance for + * future operations. + * @param {object} payload Object with all properties intended for storage. + * @param {integer} expiresIn Number of seconds intended for this model to be stored. + */ + async upsert( + id: string, + payload: AdapterPayload, + expiresIn: number, + ): Promise { + switch (this.type) { + case TCLIENT: + throw ClientNotSupportedError; + case TGRANT: + await this.upsertGrant(id, payload, expiresIn); + break; + case TREFRESH: + await this.upsertRefreshToken(id, payload); + break; + case TSESSION: + await this.upsertSession(id, payload, expiresIn); + break; + default: + await this.genericUpsert(id, payload, expiresIn); + break; + } + } + + /** + * Return previously stored instance of an oidc-provider model. + * @return {Promise} Promise fulfilled with what was previously stored for the id (when found and + * not dropped yet due to expiration) or falsy value when not found anymore. Rejected with error + * when encountered. + * @param {string} id Identifier of oidc-provider model + */ + find(id: string): Promise { + switch (this.type) { + case TCLIENT: + return this.fetchClient(id); + case TGRANT: + return this.fetchGrant(id); + case TREFRESH: + return this.fetchRefreshToken(id); + case TSESSION: + return this.fetchSession(id); + default: + return this.genericFind(id); + } + } + + /** + * Return previously stored instance of DeviceCode by the end-user entered user code. You only + * need this method for the deviceFlow feature + * @return {Promise} Promise fulfilled with the stored device code object (when found and not + * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error + * when encountered. + * @param {string} userCode the user_code value associated with a DeviceCode instance + */ + async findByUserCode( + userCode: string, + ): Promise { + const id = (await redis.get(SQLAdapter.userCodeKeyFor(userCode))) as + | string + | undefined; + if (!id) { + return undefined; + } + + return this.find(id); + } + + /** + * Return previously stored instance of Session by its uid reference property. + * @return {Promise} Promise fulfilled with the stored session object (when found and not + * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error + * when encountered. + * @param {string} uid the uid value associated with a Session instance + */ + findByUid(uid: string): Promise { + return this.fetchSessionByUid(uid); + } + + /** + * Mark a stored oidc-provider model as consumed (not yet expired though!). Future finds for this + * id should be fulfilled with an object containing additional property named "consumed" with a + * truthy value (timestamp, date, boolean, etc). + * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when + * encountered. + * @param {string} id Identifier of oidc-provider model + */ + async consume(id: string): Promise { + switch (this.type) { + case TCLIENT: + throw ClientNotSupportedError; + case TREFRESH: + await this.consumeRefreshToken(id); + break; + default: + await this.genericConsume(id); + break; + } + } + + /** + * Destroy/Drop/Remove a stored oidc-provider model. Future finds for this id should be fulfilled + * with falsy values. + * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when + * encountered. + * @param {string} id Identifier of oidc-provider model + */ + async destroy(id: string): Promise { + switch (this.type) { + case TCLIENT: + throw ClientNotSupportedError; + case TGRANT: + await this.destroyGrant(id); + break; + case TREFRESH: + await this.destroyRefreshToken(id); + break; + case TSESSION: + await this.destroySession(id); + break; + default: + await this.genericDestroy(id); + break; + } + } + + /** + * Destroy/Drop/Remove a stored oidc-provider model by its grantId property reference. Future + * finds for all tokens having this grantId value should be fulfilled with falsy values. + * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when + * encountered. + * @param {string} grantId the grantId value associated with a this model's instance + */ + async revokeByGrantId(grantId: string): Promise { + await this.revokeGrant(grantId); + } + + // Internal Functions + + // === GRANT === // + + /** + * Fetch a grant from the database, or cache if it exists. + * @param uid Grant UUID + * @returns Promise + */ + private async fetchGrant(id: string): Promise { + const cachedGrant = (await redis.get(this.key(id))) as OidcGrant | null; + + if (cachedGrant) { + return cachedGrant; + } + + const grant = await oidcGrantRepo.findOne({ + where: { id }, + }); + + if (!grant) { + return undefined; + } + + const generatedResponse = grant.generateResponse(); + + await redis.set(this.key(id), generatedResponse, globalCacheTTL); + + return generatedResponse; + } + + /** + * Upsert a grant into the database, and cache it. Do not cache past the grant's expiration. + * @param id Grant UUID + * @param payload Grant payload + * @param expiresIn Grant expiration in seconds + * @returns Promise + */ + private async upsertGrant( + id: string, + payload: AdapterPayload, + expiresIn: number, + ): Promise { + await oidcGrantRepo.upsert( + { + id, + ...payload, + }, + { + conflictPaths: { + id: true, + }, + }, + ); + + const ttl = Math.min(expiresIn, globalCacheTTL); + await redis.set(this.key(id), payload, ttl); + } + + /** + * Destroy a grant from the database, and remove it from the cache + * @param id Grant UUID + * @returns Promise + */ + private async destroyGrant(id: string): Promise { + await oidcGrantRepo.delete({ + id, + }); + + await redis.del(this.key(id)); + } + + /** + * Revoke a grant from the database, and remove it from the cache. + * @param grantId Grant UUID + * @returns Promise + */ + private async revokeGrant(grantId: string): Promise { + const grantKey = SQLAdapter.grantKeyFor(grantId); + const grantIds = await redis.lrange(grantKey, 0, -1); + + await redis.del(grantKey); + + await Promise.all(grantIds.map((id) => redis.del(id))); + + await oidcGrantRepo.delete({ + id: grantId, + }); + + await redis.del(this.key(grantId)); + } + + /// === SESSION === /// + + /** + * Fetch a session from the database, or cache if it exists. + * @param id Session UUID + * @returns Promise + */ + private async fetchSession( + id: string, + ): Promise { + const cachedSession = (await redis.get( + this.key(id), + )) as OidcSession | null; + + if (cachedSession) { + return cachedSession; + } + + const session = await oidcSessionRepo.findOne({ + where: { id }, + }); + + if (!session) { + return undefined; + } + + const generatedSession = session.generateResponse(); + + await redis.set(this.key(id), generatedSession, globalCacheTTL); + + return generatedSession; + } + + /** + * Upsert a session into the database, and cache it. Do not cache past the session's expiration. + * @param id Session UUID + * @param payload Session payload + * @param expiresIn Session expiration in seconds + * @returns Promise + */ + + private async upsertSession( + id: string, + payload: AdapterPayload, + expiresIn: number, + ): Promise { + await oidcSessionRepo.upsert( + { + id, + ...(payload as any), + }, + { + conflictPaths: { + id: true, + }, + }, + ); + + const ttl = Math.min(expiresIn, globalCacheTTL); + await redis.set(this.key(id), payload, ttl); + + if (payload.uid) { + await redis.set(SQLAdapter.uidKey(payload.uid), id, ttl); + } + } + + /** + * Fetch a session from a UUID, or cache if it exists. + * @param uid Session UUID + * @returns Promise + */ + private async fetchSessionByUid( + uid: string, + ): Promise { + const cachedSession = (await redis.get(SQLAdapter.uidKey(uid))) as + | string + | null; + + if (cachedSession) { + return this.fetchSession(cachedSession); + } + + const session = await oidcSessionRepo.findOne({ + where: { uid }, + }); + + if (!session) { + return undefined; + } + + // cache the uuid reference for the session + await redis.set(SQLAdapter.uidKey(uid), session.id, globalCacheTTL); + + return session; + } + + /** + * Destroy a session from the database, and remove it from the cache + * @param id Session UUID + * @returns Promise + */ + private async destroySession(id: string): Promise { + await oidcSessionRepo.delete({ + id, + }); + + await redis.del(this.key(id)); + } + + // === CLIENT === // + + /** + * Fetch a client from the database, or cache if it exists. + * @param id Client UUID + * @returns Promise + */ + private async fetchClient(id: string): Promise { + const cachedClient = (await redis.get(this.key(id))) as OidcClient | null; + + if (cachedClient) { + return cachedClient; + } + + const client = await oidcClientRepo.findOne({ + where: { client_id: id }, + }); + + if (!client) { + return undefined; + } + + if (!client.redirect_uris) { + client.redirect_uris = []; + } + + if ( + client.redirect_uris.some((uri) => + uri.includes('system.internalRedirect'), + ) + ) { + client.redirect_uris = client.redirect_uris.map((uri) => { + if (uri === 'system.internalRedirect.management') { + return `${baseUrl}/account/login`; + } + + return uri; + }); + } + + if (!client.post_logout_redirect_uris) { + client.post_logout_redirect_uris = []; + } + + await redis.set(this.key(id), client, globalCacheTTL); + + return client; + } + + // === REFRESH TOKEN === // + + /** + * Upserts a refresh token into the database, do not cache. + * @param id Refresh Token UUID + * @param payload Refresh Token payload + * @param expiresIn Refresh Token expiration in seconds + * @returns Promise + */ + private async upsertRefreshToken( + id: string, + payload: AdapterPayload, + ): Promise { + await oidcRefreshRepo.upsert( + { + id, + ...(payload as any), + }, + { + conflictPaths: { + id: true, + }, + }, + ); + } + + /** + * Fetch a refresh token from the database. + * @param id Refresh Token UUID + * @returns Promise + */ + private async fetchRefreshToken( + id: string, + ): Promise { + const refreshToken = await oidcRefreshRepo.findOne({ + where: { id }, + }); + + if (!refreshToken) { + return undefined; + } + + return refreshToken; + } + + /** + * Consume a refresh token from the database. + * @param id Refresh Token UUID + * @returns Promise + */ + private async consumeRefreshToken(id: string): Promise { + await oidcRefreshRepo.update( + { + id, + }, + { + consumed: Math.floor(Date.now() / 1000), + }, + ); + } + + /** + * Destroy a refresh token from the database. + * @param id Refresh Token UUID + * @returns Promise + */ + private async destroyRefreshToken(id: string): Promise { + await oidcRefreshRepo.delete({ + id, + }); + } + + // Generic Fallback Functions + + /** + * Default upsert function for all models. + * @param id Model UUID + * @param payload Model payload + * @param expiresIn Model expiration in seconds + * @returns Promise + */ + async genericUpsert( + id: string, + payload: AdapterPayload, + expiresIn: number, + ): Promise { + const multi = redis.multi(); + const key = this.key(id); + multi.call('JSON.SET', key, '.', JSON.stringify(payload)); + + // if type is GRANT + + if (expiresIn) { + multi.expire(key, expiresIn); + } + + if (grantable.has(this.name) && payload.grantId) { + const grantKey = SQLAdapter.grantKeyFor(payload.grantId); + multi.rpush(grantKey, key); + // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM + // here to trim the list to an appropriate length + const ttl = await redis.ttl(grantKey); + if (expiresIn > ttl) { + multi.expire(grantKey, expiresIn); + } + } + + if (payload.userCode) { + const userCodeKey = SQLAdapter.userCodeKeyFor(payload.userCode); + multi.set(userCodeKey, id); + multi.expire(userCodeKey, expiresIn); + } + + if (payload.uid) { + const uidKey = SQLAdapter.uidKey(payload.uid); + multi.set(uidKey, id); + multi.expire(uidKey, expiresIn); + } + + await multi.exec(); + } + + /** + * Default find function for all models. + * @param id Model UUID + * @returns Promise + */ + async genericFind(id: string): Promise { + const key = this.key(id); + const data = await redis.jsonGet(key); + + if (!data) { + return undefined; + } + + return data as AdapterPayload; + } + + /** + * Default consume function for all models. + * @param id Model UUID + */ + async genericConsume(id: string): Promise { + await redis.jsonSetPath( + this.key(id), + 'consumed', + Math.floor(Date.now() / 1000), + ); + } + + /** + * Default destroy function for all models. + * @param id Model UUID + */ + async genericDestroy(id: string): Promise { + await redis.del(this.key(id)); + } + + /** + * Auto Generate the cache key for a system + * @param id Client UUID + * @returns string + */ + key(id: string): string { + return `ww-auth:oidc:${this.name}:${id}`; + } + + /** + * Auto generate a uuid cache key for a system + * @param id Client UUID + * @returns string + */ + static uidKey(id: string): string { + return `ww-auth:oidc:uuid:uid:${id}`; + } + + /** + * Auto generate a grant array cache key for a system + * @param id Client UUID + * @returns string + */ + static grantKeyFor(id: string): string { + return `ww-auth:oidc:grantArray:${id}`; + } + + /** + * Auto generate a user code cache key for a system + * @param id Client UUID + * @returns string + */ + static userCodeKeyFor(id: string): string { + return `ww-auth:oidc:userCode:${id}`; + } + }; +}; diff --git a/src/auth/oidc/core.service.ts b/src/auth/oidc/core.service.ts index c620bab..867af3c 100644 --- a/src/auth/oidc/core.service.ts +++ b/src/auth/oidc/core.service.ts @@ -1,6 +1,22 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { promises as fs } from 'fs'; import type Provider from 'oidc-provider'; +import psl from 'psl'; import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider'; +import { createOidcAdapter } from './adapter'; +import { + ACCESS_TOKEN_LIFE, + AUTHORIZATION_TOKEN_LIFE, + CLIENT_CREDENTIALS_TOKEN_LIFE, + DEVICE_CODE_LIFE, + GRANT_LIFE, + ID_TOKEN_LIFE, + INTERACTION_LIFE, + PUSHED_AUTH_REQ_LIFE, + REFRESH_TOKEN_LIFE, + SESSION_LIFE, +} from './oidc.const'; // This is an async import for the oidc-provider package as it's now only esm and we need to use it in a commonjs environment. async function getProvider(): Promise<{ @@ -61,8 +77,265 @@ export class OidcService implements OnModuleInit { } }; - + const config = { + adapter: createOidcAdapter(this.dataSource, this.redisService, baseUrl), + extraClientMetadata: { + properties: [ + 'post_logout_redirect_uris', + 'client_cors', + 'allowed_introspection_targets', + 'redirect_uris', + 'include_permissions_from_client', + ], + validator(ctx, key, value, metadata) { + if (key === 'client_cors') { + // set default (no CORS) + if (value === undefined) { + metadata['client_cors'] = []; + return; + } + // validate an array of Origin strings + if (!Array.isArray(value) || !value.every(isOrigin)) { + throw new providerErrors.InvalidClientMetadata( + `client_cors must be an array of origins`, + ); + } + } + + if (key === 'redirect_uris') { + for (const redirectUri of value as string[]) { + if (redirectUri.includes('*')) { + const { hostname, href } = new URL(redirectUri); + + if (href.split('*').length !== 2) { + throw new providerErrors.InvalidClientMetadata( + 'redirect_uris with a wildcard may only contain a single one', + ); + } + + if (!hostname.includes('*')) { + throw new providerErrors.InvalidClientMetadata( + 'redirect_uris may only have a wildcard in the hostname', + ); + } + + const test = hostname.replace('*', 'test'); + + if (test === hostname) { + throw new providerErrors.InvalidClientMetadata( + 'redirect_uris with a wildcard must have a dot before it', + ); + } + + const domain = hostname.split('*.')[1]; + if (domain && !psl.get(domain)) { + throw new providerErrors.InvalidClientMetadata( + 'redirect_uris with a wildcard must not match an eTLD+1 of a known public suffix domain', + ); + } + } + } + } + + if (key === 'post_logout_redirect_uris') { + for (const logoutUri of value as string[]) { + if (logoutUri.includes('*')) { + const { hostname, href } = new URL(logoutUri); + + if (href.split('*').length !== 2) { + throw new providerErrors.InvalidClientMetadata( + 'post_logout_redirect_uris with a wildcard may only contain a single one', + ); + } + + if (!hostname.includes('*')) { + throw new providerErrors.InvalidClientMetadata( + 'post_logout_redirect_uris may only have a wildcard in the hostname', + ); + } + + const test = hostname.replace('*', 'test'); + + if (test === hostname) { + throw new providerErrors.InvalidClientMetadata( + 'post_logout_redirect_uris with a wildcard must have a dot before it', + ); + } + + const domain = hostname.split('*.')[1]; + if (domain && !psl.get(domain)) { + throw new providerErrors.InvalidClientMetadata( + 'post_logout_redirect_uris with a wildcard must not match an eTLD+1 of a known public suffix domain', + ); + } + } + } + } + + if ( + key === 'allowed_introspection_targets' && + (metadata['allowed_introspection_targets'] === undefined || + metadata['allowed_introspection_targets'] === null) + ) { + metadata['allowed_introspection_targets'] = []; + } + }, + }, + clientBasedCORS(ctx, origin, client) { + // ctx.oidc.route can be used to exclude endpoints from this behaviour, in that case just return + // true to always allow CORS on them, false to deny + // you may also allow some known internal origins if you want to + return (client['client_cors'] as string[]).includes(origin); + }, + jwks: this.jwks, + cookies: { + keys: this.cookies, + }, + // TODO: FUCKING IMPLEMENT THIS + findAccount: this.findAccount.bind(this), + ttl: { + AccessToken: ACCESS_TOKEN_LIFE, + RefreshToken: function RefreshTokenTTL(ctx, token, client) { + if ( + ctx.oidc.entities.RotatedRefreshToken && + client.applicationType === 'web' && + client.tokenEndpointAuthMethod === 'none' && + !token.isSenderConstrained() + ) { + // Non-Sender Constrained SPA RefreshTokens do not have infinite expiration through rotation + return ctx.oidc.entities.RotatedRefreshToken.remainingTTL; + } + + return REFRESH_TOKEN_LIFE; + }, + IdToken: ID_TOKEN_LIFE, + AuthorizationCode: AUTHORIZATION_TOKEN_LIFE, + Session: SESSION_LIFE, + DeviceCode: DEVICE_CODE_LIFE, + Grant: GRANT_LIFE, + Interaction: INTERACTION_LIFE, + PushedAuthorizationRequest: PUSHED_AUTH_REQ_LIFE, + ClientCredentials: CLIENT_CREDENTIALS_TOKEN_LIFE, + }, + issueRefreshToken(ctx, client, code) { + if (!client.grantTypeAllowed('refresh_token')) { + return false; + } + return ( + code.scopes.has('offline_access') || + (client.applicationType === 'web' && + client.tokenEndpointAuthMethod === 'none') + ); + }, + features: { + devInteractions: { + enabled: false, + }, + introspection: { + enabled: true, + allowedPolicy(ctx, client, token) { + if ( + client.clientAuthMethod === 'none' && + token.clientId !== ctx.oidc.client?.clientId + ) { + return false; + } + + if (token.clientId !== ctx.oidc.client?.clientId) { + return ( + client['allowed_introspection_targets'] as string[] + ).includes(token.clientId!); + } + return true; + }, + }, + pushedAuthorizationRequests: { + enabled: true, + }, + revocation: { + enabled: true, + }, + clientCredentials: { + enabled: true, + }, + resourceIndicators: {}, + }, + interactions: { + url: (ctx, interaction) => { + return `/interaction/${interaction.uid}`; + }, + }, + conformIdTokenClaims: false, + renderError(ctx, out, _error) { + let statusCode = 500; + let errorMessage = 'Internal Server Error'; + // Look at the first error in the out object + // If the key is error and contains invalid_request throw a 400 otherwise throw a 500 + + if ( + out.error === 'invalid_request' || + out.error === 'invalid_client' || + out.error === 'invalid_redirect_uri' + ) { + ctx.status = 400; + statusCode = 400; + errorMessage = out.error_description ?? 'Invalid Request'; + } + + const error = { + statusCode, + error: out.error, + error_description: errorMessage, + }; + + ctx.status = statusCode; + ctx.type = 'application/json'; + ctx.body = JSON.stringify(error); + }, + claims: { + openid: ['sub'], + profile: ['name', 'preferred_username', 'picture', 'displayName'], + email: ['email', 'email_verified'], + roles: ['roles', 'permissions'], + }, + pkce: { + methods: ['S256'], + required(_ctx, _client) { + return false; + }, + }, + } as Configuration; } - private async loadKeys() {} + /// === SUPPORT FUNCTIONS === /// + + /** + * Loads the OIDC jwks and cookies from the keys folder. Loads it into a variable for use in the OIDC provider + * @returns {Promise} - Returns a promise that resolves when the keys are loaded + */ + private async loadKeys(): Promise { + const keysFolder = this.getKeysFolder(); + + try { + this.jwks = JSON.parse( + await fs.readFile(keysFolder + 'keys/jwks.json', 'utf8'), + ); + this.cookies = JSON.parse( + await fs.readFile(keysFolder + 'keys/cookies.json', 'utf8'), + ); + + this.logger.debug(`Loaded oidc keys successfully`); + } catch (error) { + throw new Error( + `Failed to load keys, keys should be located ${keysFolder}keys`, + ); + } + } + + private getKeysFolder(): string { + if (process.env.NODE_ENV === 'production') { + return __dirname + '/../../../../'; + } + return __dirname + '/../../../../'; + } } diff --git a/src/database/models/oidc_refresh_token.model.ts b/src/database/models/oidc_refresh_token.model.ts new file mode 100644 index 0000000..7e9f4e5 --- /dev/null +++ b/src/database/models/oidc_refresh_token.model.ts @@ -0,0 +1,138 @@ +import type { AdapterPayload } from 'oidc-provider'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + convertFromNumberToTime, + convertFromTimeToNumber, +} from '../../util/time.util'; + +@Entity() +export class OidcRefreshToken implements AdapterPayload { + @PrimaryColumn() + id: string; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + iat?: number; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + exp?: number; + + @Index() + @Column({ + nullable: true, + type: 'varchar', + }) + accountId: string; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + authTime?: number; + + @Column({ + default: false, + nullable: true, + type: 'boolean', + }) + expiresWithSession?: boolean; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + iiat?: number; + + @Index() + @Column({ + nullable: true, + type: 'varchar', + }) + grantId?: string; + + @Column({ + nullable: true, + type: 'varchar', + }) + gty?: string; + + @Column({ + nullable: true, + type: 'int', + }) + rotations?: number; + + @Column({ + nullable: true, + type: 'varchar', + }) + scope?: string; + + @Index() + @Column({ + nullable: true, + type: 'varchar', + }) + sessionUid?: string; + + @Index() + @Column({ + nullable: true, + type: 'varchar', + }) + clientId?: string; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + consumed?: number; + + generateResponse(): AdapterPayload { + return { + iat: this.iat, + exp: this.exp, + accountId: this.accountId, + authTime: this.authTime, + expiresWithSession: this.expiresWithSession, + iiat: this.iiat, + grantId: this.grantId, + gty: this.gty, + rotations: this.rotations, + scope: this.scope, + sessionUid: this.sessionUid, + kind: 'RefreshToken', + jti: this.id, + clientId: this.clientId, + consumed: this.consumed, + }; + } + + [key: string]: unknown; +} diff --git a/src/database/models/oidc_session.model.ts b/src/database/models/oidc_session.model.ts new file mode 100644 index 0000000..c4f42ed --- /dev/null +++ b/src/database/models/oidc_session.model.ts @@ -0,0 +1,84 @@ +import type { + AdapterPayload, + ClientAuthorizationState, + UnknownObject, +} from 'oidc-provider'; +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; + +import { + convertFromNumberToTime, + convertFromTimeToNumber, +} from '../../util/time.util'; + +@Entity('oidc_session') +export class OidcSession implements AdapterPayload { + @PrimaryColumn() + id: string; + + @Index() + @PrimaryColumn() + uid: string; + + @Column({ + type: 'timestamp', + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + iat?: number; + + @Column({ + type: 'timestamp', + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + exp: number; + + @Index() + @Column({ + nullable: true, + type: 'varchar', + }) + accountId: string; + + @Column({ + type: 'timestamp', + nullable: true, + transformer: { + from: (value: Date) => convertFromTimeToNumber(value), + to: (value: number) => convertFromNumberToTime(value), + }, + }) + loginTs?: number; + + @Column({ + type: 'json', + nullable: true, + }) + authorizations?: Record; + + @Column({ + type: 'json', + nullable: true, + }) + state?: Record; + + generateResponse(): AdapterPayload { + return { + iat: this.iat, + exp: this.exp, + uid: this.uid, + accountId: this.accountId, + loginTs: this.loginTs, + kind: 'Session', + jti: this.id, + authorizations: this.authorizations, + state: this.state as UnknownObject | undefined, + }; + } + + [key: string]: unknown; +}