import type { Adapter, AdapterPayload } from 'oidc-provider'; import { config } from 'tailchat-server-sdk'; import RedisClient from 'ioredis'; import _ from 'lodash'; import { OpenApp } from './model'; const client = new RedisClient(config.redisUrl, { keyPrefix: 'tailchat:oidc:', }); const grantable = new Set([ 'AccessToken', 'AuthorizationCode', 'RefreshToken', 'DeviceCode', 'BackchannelAuthenticationRequest', ]); const consumable = new Set([ 'AuthorizationCode', 'RefreshToken', 'DeviceCode', 'BackchannelAuthenticationRequest', ]); function grantKeyFor(id) { return `grant:${id}`; } function userCodeKeyFor(userCode) { return `userCode:${userCode}`; } function uidKeyFor(uid) { return `uid:${uid}`; } /** * Reference: https://github.com/panva/node-oidc-provider/blob/main/example/my_adapter.js */ export class TcOIDCAdapter implements Adapter { constructor(public name: string) {} async upsert( id: string, payload: AdapterPayload, expiresIn: number ): Promise { const key = this.key(id); const multi = client.multi(); if (consumable.has(this.name)) { multi['hmset'](key, { payload: JSON.stringify(payload) }); } else { multi['set'](key, JSON.stringify(payload)); } if (expiresIn) { multi.expire(key, expiresIn); } if (grantable.has(this.name) && payload.grantId) { const grantKey = 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 client.ttl(grantKey); if (expiresIn > ttl) { multi.expire(grantKey, expiresIn); } } if (payload.userCode) { const userCodeKey = userCodeKeyFor(payload.userCode); multi.set(userCodeKey, id); multi.expire(userCodeKey, expiresIn); } if (payload.uid) { const uidKey = uidKeyFor(payload.uid); multi.set(uidKey, id); multi.expire(uidKey, expiresIn); } await multi.exec(); } async find(id: string): Promise { if (this.name === 'Client') { return this.findClient(id); } const data = consumable.has(this.name) ? await client.hgetall(this.key(id)) : await client.get(this.key(id)); if (_.isEmpty(data)) { return undefined; } if (typeof data === 'string') { return JSON.parse(data); } const { payload, ...rest } = data; return { ...rest, ...JSON.parse(payload), }; } async findByUid(uid: string): Promise { const id = await client.get(uidKeyFor(uid)); return this.find(id); } async findByUserCode( userCode: string ): Promise { const id = await client.get(userCodeKeyFor(userCode)); return this.find(id); } async destroy(id: string): Promise { const key = this.key(id); await client.del(key); } async revokeByGrantId(grantId: string): Promise { // eslint-disable-line class-methods-use-this const multi = client.multi(); const tokens = await client.lrange(grantKeyFor(grantId), 0, -1); tokens.forEach((token) => multi.del(token)); multi.del(grantKeyFor(grantId)); await multi.exec(); } async consume(id: string) { await client.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000)); } /** * 查询客户端 */ private async findClient(clientId: string): Promise { const app = await OpenApp.findOne({ appId: clientId, }).exec(); if (!app) { return; } if (!app.capability.includes('oauth')) { return; } const clientPayload: AdapterPayload = { client_id: app.appId, client_secret: app.appSecret, client_name: app.appName, application_type: 'web', grant_types: ['refresh_token', 'authorization_code'], redirect_uris: [...(app.oauth?.redirectUrls ?? [])], }; if (app.appIcon) { clientPayload.logo_uri = app.appIcon; } return clientPayload; } key(id: string) { return `${this.name}:${id}`; } }