Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 525a72b73b | |||
| d913dfaeb1 |
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.0 - feat(oidc)
|
||||||
|
persist hashed OIDC tokens, authorization codes, and user consent in smartdata storage
|
||||||
|
|
||||||
|
- replace in-memory OIDC authorization code, access token, refresh token, and consent stores with SmartData document classes
|
||||||
|
- store authorization codes and tokens as hashes instead of persisting plaintext values, with helpers for matching, expiration, and revocation
|
||||||
|
- persist and merge user consent scopes when issuing authorization codes
|
||||||
|
- add cleanup lifecycle management for expired OIDC state and stop the cleanup task when reception shuts down
|
||||||
|
- add tests covering hashed code/token matching, authorization code usage, refresh token revocation, and consent scope merging
|
||||||
|
|
||||||
## 2026-04-20 - 1.18.0 - feat(reception)
|
## 2026-04-20 - 1.18.0 - feat(reception)
|
||||||
persist email action tokens and registration sessions for authentication and signup flows
|
persist email action tokens and registration sessions for authentication and signup flows
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||||
|
|
||||||
|
tap.test('stores authorization codes as hashes and marks them used', async () => {
|
||||||
|
const authCode = new OidcAuthorizationCode();
|
||||||
|
authCode.id = 'oidc-auth-code';
|
||||||
|
authCode.data.codeHash = OidcAuthorizationCode.hashCode('plain-auth-code');
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(authCode as OidcAuthorizationCode & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(authCode.matchesCode('plain-auth-code')).toBeTrue();
|
||||||
|
expect(authCode.matchesCode('wrong-code')).toBeFalse();
|
||||||
|
|
||||||
|
await authCode.markUsed();
|
||||||
|
expect(authCode.data.used).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stores access tokens without plaintext persistence', async () => {
|
||||||
|
const accessToken = new OidcAccessToken();
|
||||||
|
accessToken.id = 'oidc-access-token';
|
||||||
|
accessToken.data.tokenHash = OidcAccessToken.hashToken('plain-access-token');
|
||||||
|
accessToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
expect(accessToken.matchesToken('plain-access-token')).toBeTrue();
|
||||||
|
expect(accessToken.matchesToken('different-access-token')).toBeFalse();
|
||||||
|
expect(accessToken.isExpired()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('revokes persisted refresh tokens', async () => {
|
||||||
|
const refreshToken = new OidcRefreshToken();
|
||||||
|
refreshToken.id = 'oidc-refresh-token';
|
||||||
|
refreshToken.data.tokenHash = OidcRefreshToken.hashToken('plain-refresh-token');
|
||||||
|
refreshToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(refreshToken as OidcRefreshToken & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(refreshToken.matchesToken('plain-refresh-token')).toBeTrue();
|
||||||
|
expect(refreshToken.data.revoked).toBeFalse();
|
||||||
|
|
||||||
|
await refreshToken.revoke();
|
||||||
|
expect(refreshToken.data.revoked).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('merges user consent scopes without duplicates', async () => {
|
||||||
|
const consent = new OidcUserConsent();
|
||||||
|
consent.id = 'oidc-consent';
|
||||||
|
consent.data.userId = 'user-1';
|
||||||
|
consent.data.clientId = 'client-1';
|
||||||
|
consent.data.scopes = ['openid'];
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(consent as OidcUserConsent & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
await consent.grantScopes(['openid', 'email', 'profile']);
|
||||||
|
|
||||||
|
expect(consent.data.scopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(consent.data.grantedAt).toBeGreaterThan(0);
|
||||||
|
expect(consent.data.updatedAt).toBeGreaterThan(0);
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.18.0',
|
version: '1.19.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAccessToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAccessToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcAccessToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcAccessToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcAccessToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAuthorizationCode extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAuthorizationCode,
|
||||||
|
plugins.idpInterfaces.data.IAuthorizationCode,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashCode(codeArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAuthorizationCode['data'] = {
|
||||||
|
codeHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
redirectUri: '',
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesCode(codeArg: string) {
|
||||||
|
return this.data.codeHash === OidcAuthorizationCode.hashCode(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markUsed() {
|
||||||
|
this.data.used = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import type { App } from './classes.app.js';
|
import type { App } from './classes.app.js';
|
||||||
|
import { OidcAccessToken } from './classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from './classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcRefreshToken } from './classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from './classes.oidcuserconsent.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OidcManager handles OpenID Connect (OIDC) server functionality
|
* OidcManager handles OpenID Connect (OIDC) server functionality
|
||||||
@@ -12,25 +16,31 @@ export class OidcManager {
|
|||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
this,
|
||||||
|
OidcAuthorizationCode
|
||||||
|
);
|
||||||
|
|
||||||
// In-memory store for access tokens (for validation)
|
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
|
||||||
|
|
||||||
// In-memory store for refresh tokens
|
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
|
||||||
|
|
||||||
// In-memory store for user consents (should be persisted later)
|
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
|
|
||||||
// Start cleanup task for expired codes/tokens
|
|
||||||
this.startCleanupTask();
|
this.startCleanupTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the OIDC Discovery Document
|
* Get the OIDC Discovery Document
|
||||||
*/
|
*/
|
||||||
@@ -174,9 +184,11 @@ export class OidcManager {
|
|||||||
codeChallenge?: string,
|
codeChallenge?: string,
|
||||||
nonce?: string
|
nonce?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const code = plugins.smartunique.shortId(32);
|
const code = this.createOpaqueToken();
|
||||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
const authCode = new OidcAuthorizationCode();
|
||||||
code,
|
authCode.id = plugins.smartunique.shortId(12);
|
||||||
|
authCode.data = {
|
||||||
|
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -184,11 +196,13 @@ export class OidcManager {
|
|||||||
codeChallenge,
|
codeChallenge,
|
||||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||||
nonce,
|
nonce,
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
issuedAt: Date.now(),
|
||||||
used: false,
|
used: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.authorizationCodes.set(code, authCode);
|
await authCode.save();
|
||||||
|
await this.upsertUserConsent(userId, clientId, scopes);
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,50 +275,48 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find and validate authorization code
|
// Find and validate authorization code
|
||||||
const authCode = this.authorizationCodes.get(code);
|
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||||
if (!authCode) {
|
if (!authCode) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.used) {
|
if (authCode.data.used) {
|
||||||
// Code reuse attack - revoke all tokens for this code
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.expiresAt < Date.now()) {
|
if (authCode.isExpired()) {
|
||||||
this.authorizationCodes.delete(code);
|
await authCode.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
|
if (authCode.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.redirectUri !== redirectUri) {
|
if (authCode.data.redirectUri !== redirectUri) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PKCE if code challenge was used
|
// Verify PKCE if code challenge was used
|
||||||
if (authCode.codeChallenge) {
|
if (authCode.data.codeChallenge) {
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||||
}
|
}
|
||||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||||
if (expectedChallenge !== authCode.codeChallenge) {
|
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark code as used
|
// Mark code as used
|
||||||
authCode.used = true;
|
await authCode.markUsed();
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
authCode.userId,
|
authCode.data.userId,
|
||||||
app.data.oauthCredentials.clientId,
|
app.data.oauthCredentials.clientId,
|
||||||
authCode.scopes,
|
authCode.data.scopes,
|
||||||
authCode.nonce
|
authCode.data.nonce
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(tokens), {
|
return new Response(JSON.stringify(tokens), {
|
||||||
@@ -330,31 +342,30 @@ export class OidcManager {
|
|||||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||||
const storedToken = this.refreshTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.revoked) {
|
if (storedToken.data.revoked) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < Date.now()) {
|
if (storedToken.isExpired()) {
|
||||||
this.refreshTokens.delete(tokenHash);
|
await storedToken.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.clientId !== app.data.oauthCredentials.clientId) {
|
if (storedToken.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new tokens (without new refresh token by default)
|
// Generate new tokens (without new refresh token by default)
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
storedToken.userId,
|
storedToken.data.userId,
|
||||||
storedToken.clientId,
|
storedToken.data.clientId,
|
||||||
storedToken.scopes,
|
storedToken.data.scopes,
|
||||||
undefined,
|
undefined,
|
||||||
false // Don't generate new refresh token
|
false // Don't generate new refresh token
|
||||||
);
|
);
|
||||||
@@ -384,18 +395,18 @@ export class OidcManager {
|
|||||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = plugins.smartunique.shortId(32);
|
const accessToken = this.createOpaqueToken();
|
||||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const accessTokenData = new OidcAccessToken();
|
||||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
accessTokenData.data = {
|
||||||
tokenHash: accessTokenHash,
|
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
expiresAt: now + accessTokenLifetime * 1000,
|
expiresAt: now + accessTokenLifetime * 1000,
|
||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
};
|
};
|
||||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
await accessTokenData.save();
|
||||||
|
|
||||||
// Generate ID token (JWT)
|
// Generate ID token (JWT)
|
||||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||||
@@ -410,11 +421,11 @@ export class OidcManager {
|
|||||||
|
|
||||||
// Generate refresh token if requested
|
// Generate refresh token if requested
|
||||||
if (includeRefreshToken) {
|
if (includeRefreshToken) {
|
||||||
const refreshToken = plugins.smartunique.shortId(48);
|
const refreshToken = this.createOpaqueToken(48);
|
||||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const refreshTokenData = new OidcRefreshToken();
|
||||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
refreshTokenData.data = {
|
||||||
tokenHash: refreshTokenHash,
|
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -422,7 +433,7 @@ export class OidcManager {
|
|||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
};
|
};
|
||||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
await refreshTokenData.save();
|
||||||
response.refresh_token = refreshToken;
|
response.refresh_token = refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,8 +493,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = authHeader.substring(7);
|
const accessToken = authHeader.substring(7);
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||||
const tokenData = this.accessTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!tokenData) {
|
if (!tokenData) {
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
@@ -495,8 +505,8 @@ export class OidcManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData.expiresAt < Date.now()) {
|
if (tokenData.isExpired()) {
|
||||||
this.accessTokens.delete(tokenHash);
|
await tokenData.delete();
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -507,7 +517,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user claims based on token scopes
|
// Get user claims based on token scopes
|
||||||
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
|
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||||
|
|
||||||
return new Response(JSON.stringify(userInfo), {
|
return new Response(JSON.stringify(userInfo), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -583,21 +593,20 @@ export class OidcManager {
|
|||||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
|
||||||
|
|
||||||
// Try to revoke as refresh token
|
// Try to revoke as refresh token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
refreshToken.revoked = true;
|
await refreshToken.revoke();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to revoke as access token
|
// Try to revoke as access token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||||
if (this.accessTokens.has(tokenHash)) {
|
const accessToken = await this.getAccessTokenByToken(token);
|
||||||
this.accessTokens.delete(tokenHash);
|
if (accessToken) {
|
||||||
|
await accessToken.delete();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +625,53 @@ export class OidcManager {
|
|||||||
return apps[0] || null;
|
return apps[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(byteLength = 32): string {
|
||||||
|
return plugins.crypto.randomBytes(byteLength).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthorizationCodeByCode(codeArg: string) {
|
||||||
|
return this.COidcAuthorizationCode.getInstance({
|
||||||
|
'data.codeHash': OidcAuthorizationCode.hashCode(codeArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcAccessToken.getInstance({
|
||||||
|
'data.tokenHash': OidcAccessToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRefreshTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcRefreshToken.getInstance({
|
||||||
|
'data.tokenHash': OidcRefreshToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserConsent(userIdArg: string, clientIdArg: string) {
|
||||||
|
return this.COidcUserConsent.getInstance({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.clientId': clientIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertUserConsent(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[]
|
||||||
|
) {
|
||||||
|
let userConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
|
||||||
|
if (!userConsent) {
|
||||||
|
userConsent = new OidcUserConsent();
|
||||||
|
userConsent.id = plugins.smartunique.shortId(12);
|
||||||
|
userConsent.data.userId = userIdArg;
|
||||||
|
userConsent.data.clientId = clientIdArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userConsent.grantScopes(scopesArg);
|
||||||
|
return userConsent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate S256 PKCE challenge from verifier
|
* Generate S256 PKCE challenge from verifier
|
||||||
*/
|
*/
|
||||||
@@ -655,29 +711,45 @@ export class OidcManager {
|
|||||||
* Start cleanup task for expired tokens/codes
|
* Start cleanup task for expired tokens/codes
|
||||||
*/
|
*/
|
||||||
private startCleanupTask(): void {
|
private startCleanupTask(): void {
|
||||||
setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
void this.cleanupExpiredOidcState();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired authorization codes
|
private async cleanupExpiredOidcState() {
|
||||||
for (const [code, data] of this.authorizationCodes) {
|
const now = Date.now();
|
||||||
if (data.expiresAt < now) {
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired access tokens
|
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||||
for (const [hash, data] of this.accessTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.accessTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
for (const authCode of expiredAuthorizationCodes) {
|
||||||
|
await authCode.delete();
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens
|
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||||
for (const [hash, data] of this.refreshTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.refreshTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
}, 60 * 1000); // Run every minute
|
});
|
||||||
|
for (const accessToken of expiredAccessTokens) {
|
||||||
|
await accessToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const refreshToken of expiredRefreshTokens) {
|
||||||
|
await refreshToken.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcRefreshToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcRefreshToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcRefreshToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcRefreshToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcRefreshToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke() {
|
||||||
|
this.data.revoked = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcUserConsent extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcUserConsent,
|
||||||
|
plugins.idpInterfaces.data.IUserConsent,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IUserConsent['data'] = {
|
||||||
|
userId: '',
|
||||||
|
clientId: '',
|
||||||
|
scopes: [],
|
||||||
|
grantedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async grantScopes(scopesArg: plugins.idpInterfaces.data.TOidcScope[]) {
|
||||||
|
this.data.scopes = [...new Set([...this.data.scopes, ...scopesArg])];
|
||||||
|
if (!this.data.grantedAt) {
|
||||||
|
this.data.grantedAt = Date.now();
|
||||||
|
}
|
||||||
|
this.data.updatedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,7 @@ export class Reception {
|
|||||||
*/
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.housekeeping.stop();
|
await this.housekeeping.stop();
|
||||||
|
await this.oidcManager.stop();
|
||||||
console.log('stopped serviceserver!');
|
console.log('stopped serviceserver!');
|
||||||
await this.db.stop();
|
await this.db.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,86 +11,94 @@ export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'rol
|
|||||||
* Authorization code for OAuth 2.0 authorization code flow
|
* Authorization code for OAuth 2.0 authorization code flow
|
||||||
*/
|
*/
|
||||||
export interface IAuthorizationCode {
|
export interface IAuthorizationCode {
|
||||||
/** The authorization code string */
|
id: string;
|
||||||
code: string;
|
data: {
|
||||||
/** OAuth client ID */
|
/** Hashed authorization code string */
|
||||||
clientId: string;
|
codeHash: string;
|
||||||
/** User ID who authorized */
|
/** OAuth client ID */
|
||||||
userId: string;
|
clientId: string;
|
||||||
/** Scopes granted */
|
/** User ID who authorized */
|
||||||
scopes: TOidcScope[];
|
userId: string;
|
||||||
/** Redirect URI used in authorization request */
|
/** Scopes granted */
|
||||||
redirectUri: string;
|
scopes: TOidcScope[];
|
||||||
/** PKCE code challenge (S256 hashed) */
|
/** Redirect URI used in authorization request */
|
||||||
codeChallenge?: string;
|
redirectUri: string;
|
||||||
/** PKCE code challenge method */
|
/** PKCE code challenge (S256 hashed) */
|
||||||
codeChallengeMethod?: 'S256';
|
codeChallenge?: string;
|
||||||
/** Nonce from authorization request (for ID token) */
|
/** PKCE code challenge method */
|
||||||
nonce?: string;
|
codeChallengeMethod?: 'S256';
|
||||||
/** Expiration timestamp (10 minutes from creation) */
|
/** Nonce from authorization request (for ID token) */
|
||||||
expiresAt: number;
|
nonce?: string;
|
||||||
/** Whether the code has been used (single-use) */
|
/** Expiration timestamp (10 minutes from creation) */
|
||||||
used: boolean;
|
expiresAt: number;
|
||||||
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
/** Whether the code has been used (single-use) */
|
||||||
|
used: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Access Token (opaque or JWT)
|
* OIDC Access Token (opaque or JWT)
|
||||||
*/
|
*/
|
||||||
export interface IOidcAccessToken {
|
export interface IOidcAccessToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The access token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The access token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Refresh Token
|
* OIDC Refresh Token
|
||||||
*/
|
*/
|
||||||
export interface IOidcRefreshToken {
|
export interface IOidcRefreshToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The refresh token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The refresh token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
/** Whether the token has been revoked */
|
issuedAt: number;
|
||||||
revoked: boolean;
|
/** Whether the token has been revoked */
|
||||||
|
revoked: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User consent record for an OAuth client
|
* User consent record for an OAuth client
|
||||||
*/
|
*/
|
||||||
export interface IUserConsent {
|
export interface IUserConsent {
|
||||||
/** Unique identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** User who gave consent */
|
data: {
|
||||||
userId: string;
|
/** User who gave consent */
|
||||||
/** OAuth client ID */
|
userId: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** Scopes the user consented to */
|
clientId: string;
|
||||||
scopes: TOidcScope[];
|
/** Scopes the user consented to */
|
||||||
/** When consent was granted */
|
scopes: TOidcScope[];
|
||||||
grantedAt: number;
|
/** When consent was granted */
|
||||||
/** When consent was last updated */
|
grantedAt: number;
|
||||||
updatedAt: number;
|
/** When consent was last updated */
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.18.0',
|
version: '1.19.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user