Compare commits
2 commits
64ce205296
...
b38f5841e3
Author | SHA1 | Date | |
---|---|---|---|
b38f5841e3 | |||
01b0e373d2 |
9 changed files with 1161 additions and 20 deletions
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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<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 + '/../../../../';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
ClientAuthMethod,
|
||||
ResponseType,
|
||||
SigningAlgorithmWithNone,
|
||||
} from 'oidc-provider';
|
||||
import type { ClientAuthMethod, ResponseType } from 'oidc-provider';
|
||||
|
||||
import {
|
||||
Column,
|
||||
|
|
138
src/database/models/oidc_refresh_token.model.ts
Normal file
138
src/database/models/oidc_refresh_token.model.ts
Normal 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;
|
||||
}
|
84
src/database/models/oidc_session.model.ts
Normal file
84
src/database/models/oidc_session.model.ts
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue