Skip to content

Commit

Permalink
redis adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
NextFire committed Apr 9, 2024
1 parent b60411f commit 2a82159
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 23 deletions.
1 change: 1 addition & 0 deletions nanaoidc.example.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"publicUrl": "http://localhost:3000",
"sessionPassword": "openssl rand -hex 32",
"redisUrl": "redis://localhost:6379",
"oidc": {
"cookies": {
"keys": ["openssl rand -hex 32"]
Expand Down
24 changes: 24 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 3 additions & 3 deletions server/routes/interaction/[uid]/callback.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 0 additions & 1 deletion server/routes/interaction/[uid]/index.get.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
46 changes: 30 additions & 16 deletions server/utils/account.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,44 +15,52 @@ export interface UserInfos {
member: RESTGetCurrentUserGuildMemberResult;
}

export const userStore = new Map<Snowflake, UserInfos>();
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<UserInfos> {
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,
birthdate: undefined,
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,
Expand All @@ -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);
Expand All @@ -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);
};
Expand Down
126 changes: 126 additions & 0 deletions server/utils/adapter.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
1 change: 1 addition & 0 deletions server/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[];
Expand Down
8 changes: 5 additions & 3 deletions server/utils/provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Provider, { type Configuration } from "oidc-provider";

const config: Configuration = {
adapter: RedisAdapter,
clients: userConfig.clients,
findAccount: Account.findAccount,
claims: {
Expand Down Expand Up @@ -28,6 +29,7 @@ const config: Configuration = {
cookies: {
keys: userConfig.oidc.cookies.keys,
},
expiresWithSession: () => false,
features: {
devInteractions: { enabled: false },
},
Expand All @@ -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) => {
Expand All @@ -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 */,
},
};

Expand Down

0 comments on commit 2a82159

Please sign in to comment.