feat: implement oidc-core base from private project

This commit is contained in:
Kakious 2024-07-18 22:19:14 -04:00
parent 01b0e373d2
commit b38f5841e3
8 changed files with 1160 additions and 15 deletions

View file

@ -46,6 +46,7 @@
"nestjs-otel": "^6.1.1", "nestjs-otel": "^6.1.1",
"nestjs-postal-client": "^0.0.6", "nestjs-postal-client": "^0.0.6",
"oidc-provider": "^8.5.1", "oidc-provider": "^8.5.1",
"psl": "^1.9.0",
"pug": "^3.0.3", "pug": "^3.0.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View file

@ -68,6 +68,9 @@ importers:
oidc-provider: oidc-provider:
specifier: ^8.5.1 specifier: ^8.5.1
version: 8.5.1 version: 8.5.1
psl:
specifier: ^1.9.0
version: 1.9.0
pug: pug:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@ -3188,6 +3191,9 @@ packages:
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
pug-attrs@3.0.0: pug-attrs@3.0.0:
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
@ -7597,6 +7603,8 @@ snapshots:
proxy-from-env@1.1.0: {} proxy-from-env@1.1.0: {}
psl@1.9.0: {}
pug-attrs@3.0.0: pug-attrs@3.0.0:
dependencies: dependencies:
constantinople: 4.0.1 constantinople: 4.0.1

View file

@ -1,2 +1,3 @@
// This is the internal client ID for the application itself. // 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';

View file

@ -1,12 +0,0 @@
import { IsEmail } from 'class-validator';
import { Column, Model, Table } from 'sequelize-typescript';
@Table
export class User extends Model<User> {
@Column
username: string;
@Column
@IsEmail()
email: string;
}

View file

@ -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<void> {
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<AdapterPayload | undefined> {
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<AdapterPayload | undefined> {
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<AdapterPayload | undefined> {
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<void> {
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<void> {
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<void> {
await this.revokeGrant(grantId);
}
// Internal Functions
// === GRANT === //
/**
* Fetch a grant from the database, or cache if it exists.
* @param uid Grant UUID
* @returns Promise<OidcGrant | undefined>
*/
private async fetchGrant(id: string): Promise<AdapterPayload | undefined> {
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<void | undefined>
*/
private async upsertGrant(
id: string,
payload: AdapterPayload,
expiresIn: number,
): Promise<void> {
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<void | undefined>
*/
private async destroyGrant(id: string): Promise<void> {
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<void | undefined>
*/
private async revokeGrant(grantId: string): Promise<void> {
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<OidcSession | undefined>
*/
private async fetchSession(
id: string,
): Promise<AdapterPayload | undefined> {
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<void | undefined>
*/
private async upsertSession(
id: string,
payload: AdapterPayload,
expiresIn: number,
): Promise<void> {
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<OidcSession | undefined>
*/
private async fetchSessionByUid(
uid: string,
): Promise<AdapterPayload | undefined> {
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<void | undefined>
*/
private async destroySession(id: string): Promise<void> {
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<OidcClient | undefined>
*/
private async fetchClient(id: string): Promise<AdapterPayload | undefined> {
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<void | undefined>
*/
private async upsertRefreshToken(
id: string,
payload: AdapterPayload,
): Promise<void> {
await oidcRefreshRepo.upsert(
{
id,
...(payload as any),
},
{
conflictPaths: {
id: true,
},
},
);
}
/**
* Fetch a refresh token from the database.
* @param id Refresh Token UUID
* @returns Promise<OidcRefreshToken | undefined>
*/
private async fetchRefreshToken(
id: string,
): Promise<AdapterPayload | undefined> {
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<void | undefined>
*/
private async consumeRefreshToken(id: string): Promise<void> {
await oidcRefreshRepo.update(
{
id,
},
{
consumed: Math.floor(Date.now() / 1000),
},
);
}
/**
* Destroy a refresh token from the database.
* @param id Refresh Token UUID
* @returns Promise<void | undefined>
*/
private async destroyRefreshToken(id: string): Promise<void> {
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<OidcModel | undefined>
*/
async genericUpsert(
id: string,
payload: AdapterPayload,
expiresIn: number,
): Promise<void> {
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<OidcModel | undefined>
*/
async genericFind(id: string): Promise<AdapterPayload | undefined> {
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<void> {
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<void> {
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}`;
}
};
};

View file

@ -1,6 +1,22 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { promises as fs } from 'fs';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import psl from 'psl';
import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider'; 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. // 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<{ 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<void>} - Returns a promise that resolves when the keys are loaded
*/
private async loadKeys(): Promise<void> {
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 + '/../../../../';
}
} }

View file

@ -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;
}

View file

@ -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<string, ClientAuthorizationState>;
@Column({
type: 'json',
nullable: true,
})
state?: Record<string, string>;
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;
}