feat(oidc): persist hashed OIDC tokens, authorization codes, and user consent in smartdata storage
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Reception } from './classes.reception.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
|
||||
@@ -12,25 +16,31 @@ export class OidcManager {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
|
||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
||||
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||
this,
|
||||
OidcAuthorizationCode
|
||||
);
|
||||
|
||||
// In-memory store for access tokens (for validation)
|
||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
||||
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||
|
||||
// In-memory store for refresh tokens
|
||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
||||
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||
|
||||
// In-memory store for user consents (should be persisted later)
|
||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
||||
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
|
||||
// Start cleanup task for expired codes/tokens
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC Discovery Document
|
||||
*/
|
||||
@@ -174,9 +184,11 @@ export class OidcManager {
|
||||
codeChallenge?: string,
|
||||
nonce?: string
|
||||
): Promise<string> {
|
||||
const code = plugins.smartunique.shortId(32);
|
||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
||||
code,
|
||||
const code = this.createOpaqueToken();
|
||||
const authCode = new OidcAuthorizationCode();
|
||||
authCode.id = plugins.smartunique.shortId(12);
|
||||
authCode.data = {
|
||||
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -184,11 +196,13 @@ export class OidcManager {
|
||||
codeChallenge,
|
||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||
nonce,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
issuedAt: Date.now(),
|
||||
used: false,
|
||||
};
|
||||
|
||||
this.authorizationCodes.set(code, authCode);
|
||||
await authCode.save();
|
||||
await this.upsertUserConsent(userId, clientId, scopes);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -261,50 +275,48 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// Find and validate authorization code
|
||||
const authCode = this.authorizationCodes.get(code);
|
||||
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||
if (!authCode) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||
}
|
||||
|
||||
if (authCode.used) {
|
||||
// Code reuse attack - revoke all tokens for this code
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.data.used) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||
}
|
||||
|
||||
if (authCode.expiresAt < Date.now()) {
|
||||
this.authorizationCodes.delete(code);
|
||||
if (authCode.isExpired()) {
|
||||
await authCode.delete();
|
||||
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');
|
||||
}
|
||||
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
if (authCode.data.redirectUri !== redirectUri) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||
}
|
||||
|
||||
// Verify PKCE if code challenge was used
|
||||
if (authCode.codeChallenge) {
|
||||
if (authCode.data.codeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||
}
|
||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||
if (expectedChallenge !== authCode.codeChallenge) {
|
||||
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||
}
|
||||
}
|
||||
|
||||
// Mark code as used
|
||||
authCode.used = true;
|
||||
await authCode.markUsed();
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(
|
||||
authCode.userId,
|
||||
authCode.data.userId,
|
||||
app.data.oauthCredentials.clientId,
|
||||
authCode.scopes,
|
||||
authCode.nonce
|
||||
authCode.data.scopes,
|
||||
authCode.data.nonce
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(tokens), {
|
||||
@@ -330,31 +342,30 @@ export class OidcManager {
|
||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const storedToken = this.refreshTokens.get(tokenHash);
|
||||
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||
|
||||
if (!storedToken) {
|
||||
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');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < Date.now()) {
|
||||
this.refreshTokens.delete(tokenHash);
|
||||
if (storedToken.isExpired()) {
|
||||
await storedToken.delete();
|
||||
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');
|
||||
}
|
||||
|
||||
// Generate new tokens (without new refresh token by default)
|
||||
const tokens = await this.generateTokens(
|
||||
storedToken.userId,
|
||||
storedToken.clientId,
|
||||
storedToken.scopes,
|
||||
storedToken.data.userId,
|
||||
storedToken.data.clientId,
|
||||
storedToken.data.scopes,
|
||||
undefined,
|
||||
false // Don't generate new refresh token
|
||||
);
|
||||
@@ -384,18 +395,18 @@ export class OidcManager {
|
||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||
|
||||
// Generate access token
|
||||
const accessToken = plugins.smartunique.shortId(32);
|
||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: accessTokenHash,
|
||||
const accessToken = this.createOpaqueToken();
|
||||
const accessTokenData = new OidcAccessToken();
|
||||
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||
accessTokenData.data = {
|
||||
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
expiresAt: now + accessTokenLifetime * 1000,
|
||||
issuedAt: now,
|
||||
};
|
||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
||||
await accessTokenData.save();
|
||||
|
||||
// Generate ID token (JWT)
|
||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||
@@ -410,11 +421,11 @@ export class OidcManager {
|
||||
|
||||
// Generate refresh token if requested
|
||||
if (includeRefreshToken) {
|
||||
const refreshToken = plugins.smartunique.shortId(48);
|
||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
||||
id: plugins.smartunique.shortId(8),
|
||||
tokenHash: refreshTokenHash,
|
||||
const refreshToken = this.createOpaqueToken(48);
|
||||
const refreshTokenData = new OidcRefreshToken();
|
||||
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||
refreshTokenData.data = {
|
||||
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||
clientId,
|
||||
userId,
|
||||
scopes,
|
||||
@@ -422,7 +433,7 @@ export class OidcManager {
|
||||
issuedAt: now,
|
||||
revoked: false,
|
||||
};
|
||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
||||
await refreshTokenData.save();
|
||||
response.refresh_token = refreshToken;
|
||||
}
|
||||
|
||||
@@ -482,8 +493,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
const accessToken = authHeader.substring(7);
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
||||
const tokenData = this.accessTokens.get(tokenHash);
|
||||
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||
|
||||
if (!tokenData) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||
@@ -495,8 +505,8 @@ export class OidcManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenData.expiresAt < Date.now()) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
if (tokenData.isExpired()) {
|
||||
await tokenData.delete();
|
||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
@@ -507,7 +517,7 @@ export class OidcManager {
|
||||
}
|
||||
|
||||
// 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), {
|
||||
status: 200,
|
||||
@@ -583,21 +593,20 @@ export class OidcManager {
|
||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||
}
|
||||
|
||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
||||
|
||||
// Try to revoke as refresh token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
||||
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||
if (refreshToken) {
|
||||
refreshToken.revoked = true;
|
||||
await refreshToken.revoke();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Try to revoke as access token
|
||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||
if (this.accessTokens.has(tokenHash)) {
|
||||
this.accessTokens.delete(tokenHash);
|
||||
const accessToken = await this.getAccessTokenByToken(token);
|
||||
if (accessToken) {
|
||||
await accessToken.delete();
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -616,6 +625,53 @@ export class OidcManager {
|
||||
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
|
||||
*/
|
||||
@@ -655,29 +711,45 @@ export class OidcManager {
|
||||
* Start cleanup task for expired tokens/codes
|
||||
*/
|
||||
private startCleanupTask(): void {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
void this.cleanupExpiredOidcState();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
// Clean up expired authorization codes
|
||||
for (const [code, data] of this.authorizationCodes) {
|
||||
if (data.expiresAt < now) {
|
||||
this.authorizationCodes.delete(code);
|
||||
}
|
||||
}
|
||||
private async cleanupExpiredOidcState() {
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up expired access tokens
|
||||
for (const [hash, data] of this.accessTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.accessTokens.delete(hash);
|
||||
}
|
||||
}
|
||||
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
for (const authCode of expiredAuthorizationCodes) {
|
||||
await authCode.delete();
|
||||
}
|
||||
|
||||
// Clean up expired refresh tokens
|
||||
for (const [hash, data] of this.refreshTokens) {
|
||||
if (data.expiresAt < now) {
|
||||
this.refreshTokens.delete(hash);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000); // Run every minute
|
||||
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||
data: {
|
||||
expiresAt: {
|
||||
$lt: now,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user