feat: implement oidc-core base from private project
This commit is contained in:
parent
01b0e373d2
commit
b38f5841e3
8 changed files with 1160 additions and 15 deletions
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 { 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadKeys() {}
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// === 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 + '/../../../../';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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