diff --git a/nanaoidc.example.json b/nanaoidc.example.json index 343c4dc..7854af1 100644 --- a/nanaoidc.example.json +++ b/nanaoidc.example.json @@ -1,6 +1,7 @@ { "publicUrl": "http://localhost:3000", "sessionPassword": "openssl rand -hex 32", + "redisUrl": "redis://localhost:6379", "oidc": { "cookies": { "keys": ["openssl rand -hex 32"] diff --git a/package-lock.json b/package-lock.json index 237b79a..e2ef58d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,11 @@ "license": "LGPL-3.0-only", "devDependencies": { "@discordjs/rest": "2.2.0", + "@types/lodash-es": "4.17.12", "@types/oidc-provider": "8.4.4", "discord-api-types": "0.37.79", + "ioredis": "5.3.2", + "lodash-es": "4.17.21", "nitropack": "2.9.6", "oidc-provider": "8.4.5" } @@ -1617,6 +1620,21 @@ "@types/koa": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", + "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "dev": true + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4035,6 +4053,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "dev": true + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", diff --git a/package.json b/package.json index 8475cb6..341b35d 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,11 @@ }, "devDependencies": { "@discordjs/rest": "2.2.0", + "@types/lodash-es": "4.17.12", "@types/oidc-provider": "8.4.4", "discord-api-types": "0.37.79", + "ioredis": "5.3.2", + "lodash-es": "4.17.21", "nitropack": "2.9.6", "oidc-provider": "8.4.5" } diff --git a/server/routes/interaction/[uid]/callback.get.ts b/server/routes/interaction/[uid]/callback.get.ts index 9693276..f7679e6 100644 --- a/server/routes/interaction/[uid]/callback.get.ts +++ b/server/routes/interaction/[uid]/callback.get.ts @@ -7,11 +7,11 @@ export default eventHandler(async (event) => { assert(typeof code === "string"); const resp = await exchangeCode(code); - const { user, member } = await fetchUserinfo(resp.access_token); - userStore.set(user.id, { user, member }); + const infos = await fetchUserinfo(resp.access_token); + await Account.save(infos); const result: InteractionResults = { - login: { accountId: user.id }, + login: { accountId: infos.user.id }, }; const { req, res } = event.node; const redirectTo = await provider.interactionResult(req, res, result); diff --git a/server/routes/interaction/[uid]/index.get.ts b/server/routes/interaction/[uid]/index.get.ts index fc015d0..21c09b8 100644 --- a/server/routes/interaction/[uid]/index.get.ts +++ b/server/routes/interaction/[uid]/index.get.ts @@ -1,6 +1,5 @@ export default eventHandler(async (event) => { const { req, res } = event.node; const interaction = await provider.interactionDetails(req, res); - return sendRedirect(event, `${event.path}/${interaction.prompt.name}`); }); diff --git a/server/utils/account.ts b/server/utils/account.ts index ec81abf..063c856 100644 --- a/server/utils/account.ts +++ b/server/utils/account.ts @@ -1,8 +1,9 @@ import { type RESTGetAPICurrentUserResult, type RESTGetCurrentUserGuildMemberResult, - type Snowflake, } from "discord-api-types/v10"; +import Redis from "ioredis"; +import assert from "node:assert/strict"; import { type AccountClaims, type FindAccount, @@ -14,31 +15,39 @@ export interface UserInfos { member: RESTGetCurrentUserGuildMemberResult; } -export const userStore = new Map(); +const client = new Redis(userConfig.redisUrl, { keyPrefix: "discord:" }); + +function userKeyFor(id: string) { + return `user:${id}`; +} export class Account implements OidcAccount { [key: string]: unknown; constructor(public accountId: string) {} - get infos(): UserInfos { - return userStore.get(this.accountId); + async getInfos(): Promise { + const data = await client.get(userKeyFor(this.accountId)); + assert(data !== null); + return JSON.parse(data); } claims: OidcAccount["claims"] = async (use, scope, claims, rejected) => { + const { user, member } = await this.getInfos(); + const scopeSet = new Set(scope.split(" ")); // TODO: manage claims and rejected let result: AccountClaims = { - sub: this.infos.user.email, + sub: user.email, }; if (scopeSet.has("profile")) { let picture: string; - if (this.infos.member.avatar) { - picture = `https://cdn.discordapp.com/guilds/${userConfig.discord.guildId}/users/${this.infos.user.id}/avatars/${this.infos.member.avatar}.webp`; - } else if (this.infos.user.avatar) { - picture = `https://cdn.discordapp.com/avatars/${this.infos.user.id}/${this.infos.user.avatar}.webp`; + if (member.avatar) { + picture = `https://cdn.discordapp.com/guilds/${userConfig.discord.guildId}/users/${user.id}/avatars/${member.avatar}.webp`; + } else if (user.avatar) { + picture = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.webp`; } result = { ...result, @@ -46,12 +55,12 @@ export class Account implements OidcAccount { family_name: undefined, gender: undefined, given_name: undefined, - locale: this.infos.user.locale, + locale: user.locale, middle_name: undefined, - name: this.infos.user.global_name, - nickname: this.infos.member.nick, + name: user.global_name, + nickname: member.nick, picture, - preferred_username: this.infos.user.username, + preferred_username: user.username, profile: undefined, updated_at: undefined, website: undefined, @@ -62,14 +71,14 @@ export class Account implements OidcAccount { if (scopeSet.has("email")) { result = { ...result, - email: this.infos.user.email, - email_verified: this.infos.user.verified, + email: user.email, + email_verified: user.verified, }; } if (scopeSet.has("groups")) { const groups = [userConfig.baseGroup]; - for (const role of this.infos.member.roles) { + for (const role of member.roles) { const mapped = userConfig.discord.roles[role]; if (mapped) { groups.push(mapped); @@ -81,6 +90,11 @@ export class Account implements OidcAccount { return result; }; + static async save(infos: UserInfos) { + assert(infos.user.id === infos.member.user.id); + await client.set(userKeyFor(infos.user.id), JSON.stringify(infos)); + } + static findAccount: FindAccount = async (ctx, sub, token) => { return new this(sub); }; diff --git a/server/utils/adapter.ts b/server/utils/adapter.ts new file mode 100644 index 0000000..a1d37f5 --- /dev/null +++ b/server/utils/adapter.ts @@ -0,0 +1,126 @@ +// https://github.com/panva/node-oidc-provider/blob/87cd3c5c335cb30074612b405bd581c6bc76a98d/example/adapters/redis.js +import Redis from "ioredis"; +import { isEmpty } from "lodash-es"; +import type { Adapter, AdapterPayload } from "oidc-provider"; + +const client = new Redis(userConfig.redisUrl, { keyPrefix: "oidc:" }); + +const grantable = new Set([ + "AccessToken", + "AuthorizationCode", + "RefreshToken", + "DeviceCode", + "BackchannelAuthenticationRequest", +]); + +const consumable = new Set([ + "AuthorizationCode", + "RefreshToken", + "DeviceCode", + "BackchannelAuthenticationRequest", +]); + +function grantKeyFor(id: string) { + return `grant:${id}`; +} + +function userCodeKeyFor(userCode: string) { + return `userCode:${userCode}`; +} + +function uidKeyFor(uid: string) { + return `uid:${uid}`; +} + +export class RedisAdapter implements Adapter { + constructor(public name: string) {} + + key(id: string) { + return `${this.name}:${id}`; + } + + async upsert(id: string, payload: AdapterPayload, expiresIn: number) { + const key = this.key(id); + const store = consumable.has(this.name) + ? { payload: JSON.stringify(payload) } + : JSON.stringify(payload); + + const multi = client.multi(); + multi[consumable.has(this.name) ? "hmset" : "set"](key, store as any); + + 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) { + 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 findByUserCode(userCode: string) { + const id = await client.get(userCodeKeyFor(userCode)); + return this.find(id); + } + + async findByUid(uid: string) { + const id = await client.get(uidKeyFor(uid)); + return this.find(id); + } + + async consume(id: string) { + await client.hset(this.key(id), "consumed", Math.floor(Date.now() / 1000)); + } + + async destroy(id: string) { + const key = this.key(id); + await client.del(key); + } + + async revokeByGrantId(grantId: string) { + 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(); + } +} diff --git a/server/utils/config.ts b/server/utils/config.ts index 3982d0c..12f29cc 100644 --- a/server/utils/config.ts +++ b/server/utils/config.ts @@ -5,6 +5,7 @@ import type { ClientMetadata, JWKS } from "oidc-provider"; export interface UserConfig { publicUrl: string; sessionPassword: string; + redisUrl: string; oidc: { cookies: { keys: (string | Buffer)[]; diff --git a/server/utils/provider.ts b/server/utils/provider.ts index 1a90d5f..13aff39 100644 --- a/server/utils/provider.ts +++ b/server/utils/provider.ts @@ -1,6 +1,7 @@ import Provider, { type Configuration } from "oidc-provider"; const config: Configuration = { + adapter: RedisAdapter, clients: userConfig.clients, findAccount: Account.findAccount, claims: { @@ -28,6 +29,7 @@ const config: Configuration = { cookies: { keys: userConfig.oidc.cookies.keys, }, + expiresWithSession: () => false, features: { devInteractions: { enabled: false }, }, @@ -48,7 +50,7 @@ const config: Configuration = { return token.resourceServer?.accessTokenTTL || 10 * 60; // 10 minutes in seconds }, DeviceCode: 600 /* 10 minutes in seconds */, - Grant: 7 * 24 * 60 * 60 /* 7 days in seconds */, + Grant: 24 * 60 * 60 /* 1 day in seconds */, IdToken: 3600 /* 1 hour in seconds */, Interaction: 3600 /* 1 hour in seconds */, RefreshToken: (ctx, token, client) => { @@ -62,9 +64,9 @@ const config: Configuration = { // Non-Sender Constrained SPA RefreshTokens do not have infinite expiration through rotation return ctx.oidc.entities.RotatedRefreshToken.remainingTTL; } - return 7 * 24 * 60 * 60; // 7 days in seconds + return 24 * 60 * 60; // 1 day in seconds }, - Session: 7 * 24 * 60 * 60 /* 7 days in seconds */, + Session: 24 * 60 * 60 /* 1 day in seconds */, }, };