use MemoryKVCache for oauth store
This commit is contained in:
parent
2c6379649a
commit
cbaae2201f
2 changed files with 36 additions and 193 deletions
|
@ -6,14 +6,12 @@ import httpLinkHeader from 'http-link-header';
|
|||
import ipaddr from 'ipaddr.js';
|
||||
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req } from 'oauth2orize';
|
||||
import oauth2Pkce from 'oauth2orize-pkce';
|
||||
import expressSession from 'express-session';
|
||||
import fastifyView from '@fastify/view';
|
||||
import pug from 'pug';
|
||||
import bodyParser from 'body-parser';
|
||||
import fastifyExpress from '@fastify/express';
|
||||
import { verifyChallenge } from 'pkce-challenge';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { kinds } from '@/misc/api-permissions.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -24,7 +22,6 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { LocalUser } from '@/models/entities/User.js';
|
||||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import type * as Redis from 'ioredis';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-identifier
|
||||
|
@ -105,153 +102,6 @@ async function discoverClientInformation(httpRequestService: HttpRequestService,
|
|||
}
|
||||
}
|
||||
|
||||
// class MisskeyAdapter implements Adapter {
|
||||
// name = 'oauth2';
|
||||
|
||||
// constructor(private redisClient: Redis.Redis, private httpRequestService: HttpRequestService) { }
|
||||
|
||||
// key(id: string): string {
|
||||
// return `oauth2:${id}`;
|
||||
// }
|
||||
|
||||
// async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
|
||||
// console.log('oauth upsert', id, payload, expiresIn);
|
||||
|
||||
// const key = this.key(id);
|
||||
|
||||
// const multi = this.redisClient.multi();
|
||||
// if (consumable.has(this.name)) {
|
||||
// multi.hset(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 this.redisClient.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<void | AdapterPayload> {
|
||||
// console.log('oauth find', id);
|
||||
|
||||
// // XXX: really?
|
||||
// const fromRedis = await this.findRedis(id);
|
||||
// if (fromRedis) {
|
||||
// return fromRedis;
|
||||
// }
|
||||
|
||||
// // Find client information from the remote.
|
||||
// const url = validateClientId(id);
|
||||
|
||||
// if (process.env.NODE_ENV !== 'test') {
|
||||
// const lookup = await dns.lookup(url.hostname);
|
||||
// if (ipaddr.parse(lookup.address).range() === 'loopback') {
|
||||
// throw new Error('client_id unexpectedly resolves to loopback IP.');
|
||||
// }
|
||||
// }
|
||||
|
||||
// const redirectUri = await fetchFromClientId(this.httpRequestService, id);
|
||||
// if (!redirectUri) {
|
||||
// // IndieAuth also implicitly allows any path under the same scheme+host,
|
||||
// // but oidc-provider requires explicit list of uris.
|
||||
// throw new Error('The URL of client_id must provide `redirect_uri` as HTTP Link header or HTML <link> element.');
|
||||
// }
|
||||
|
||||
// return {
|
||||
// client_id: id,
|
||||
// token_endpoint_auth_method: 'none',
|
||||
// redirect_uris: [redirectUri],
|
||||
// };
|
||||
// }
|
||||
|
||||
// async findRedis(id: string | null): Promise<void | AdapterPayload> {
|
||||
// if (!id) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const data = consumable.has(this.name)
|
||||
// ? await this.redisClient.hgetall(this.key(id))
|
||||
// : await this.redisClient.get(this.key(id));
|
||||
|
||||
// if (!data || (typeof data === 'object' && !Object.entries(data).length)) {
|
||||
// return undefined;
|
||||
// }
|
||||
|
||||
// if (typeof data === 'string') {
|
||||
// return JSON.parse(data);
|
||||
// }
|
||||
// const { payload, ...rest } = data as any;
|
||||
// return {
|
||||
// ...rest,
|
||||
// ...JSON.parse(payload),
|
||||
// };
|
||||
// }
|
||||
|
||||
// async findByUserCode(userCode: string): Promise<void | AdapterPayload> {
|
||||
// console.log('oauth findByUserCode', userCode);
|
||||
// const id = await this.redisClient.get(userCodeKeyFor(userCode));
|
||||
// return this.findRedis(id);
|
||||
// }
|
||||
|
||||
// async findByUid(uid: string): Promise<void | AdapterPayload> {
|
||||
// console.log('oauth findByUid', uid);
|
||||
// const id = await this.redisClient.get(uidKeyFor(uid));
|
||||
// return this.findRedis(id);
|
||||
// }
|
||||
|
||||
// async consume(id: string): Promise<void> {
|
||||
// console.log('oauth consume', id);
|
||||
// await this.redisClient.hset(this.key(id), 'consumed', Math.floor(Date.now() / 1000));
|
||||
// }
|
||||
|
||||
// async destroy(id: string): Promise<void | undefined> {
|
||||
// console.log('oauth destroy', id);
|
||||
// const key = this.key(id);
|
||||
// await this.redisClient.del(key);
|
||||
// }
|
||||
|
||||
// async revokeByGrantId(grantId: string): Promise<void | undefined> {
|
||||
// console.log('oauth revokeByGrandId', grantId);
|
||||
// const multi = this.redisClient.multi();
|
||||
// const tokens = await this.redisClient.lrange(grantKeyFor(grantId), 0, -1);
|
||||
// tokens.forEach((token) => multi.del(token));
|
||||
// multi.del(grantKeyFor(grantId));
|
||||
// await multi.exec();
|
||||
// }
|
||||
// }
|
||||
|
||||
// function promisify<T>(callback: T) {
|
||||
// return (...args: Parameters<T>) => {
|
||||
|
||||
// args[args.length - 1]();
|
||||
// };
|
||||
// }
|
||||
|
||||
type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)]
|
||||
? R
|
||||
: [];
|
||||
|
@ -261,18 +111,47 @@ interface OAuthRequest extends OAuth2Req {
|
|||
codeChallengeMethod: string;
|
||||
}
|
||||
|
||||
class OAuth2Store {
|
||||
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // 5min
|
||||
|
||||
load(req: any, cb: (err: Error | null, txn?: OAuth2) => void): void {
|
||||
console.log(req);
|
||||
const { transaction_id } = req.body;
|
||||
if (!transaction_id) {
|
||||
cb(new Error('Missing transaction ID'));
|
||||
return;
|
||||
}
|
||||
const loaded = this.#cache.get(transaction_id);
|
||||
if (!loaded) {
|
||||
cb(new Error('Failed to load transaction'));
|
||||
return;
|
||||
}
|
||||
cb(null, loaded);
|
||||
}
|
||||
|
||||
store(req: any, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
|
||||
const transactionId = secureRndstr(128, true);
|
||||
this.#cache.set(transactionId, oauth2);
|
||||
cb(null, transactionId);
|
||||
}
|
||||
|
||||
remove(req: any, tid: string, cb: () => void): void {
|
||||
this.#cache.delete(tid);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2ProviderService {
|
||||
// #provider: Provider;
|
||||
#server = oauth2orize.createServer();
|
||||
#server = oauth2orize.createServer({
|
||||
store: new OAuth2Store(),
|
||||
});
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private metaService: MetaService,
|
||||
@Inject(DI.accessTokensRepository)
|
||||
accessTokensRepository: AccessTokensRepository,
|
||||
idService: IdService,
|
||||
|
@ -280,30 +159,6 @@ export class OAuth2ProviderService {
|
|||
private usersRepository: UsersRepository,
|
||||
private cacheService: CacheService,
|
||||
) {
|
||||
// this.#provider = new Provider(config.url, {
|
||||
// clientAuthMethods: ['none'],
|
||||
// pkce: {
|
||||
// // This is the default, but be explicit here as we announce it below
|
||||
// methods: ['S256'],
|
||||
// },
|
||||
// routes: {
|
||||
// // defaults to '/auth' but '/authorize' is more consistent with many
|
||||
// // other services eg. Mastodon/Twitter/Facebook/GitLab/GitHub/etc.
|
||||
// authorization: '/authorize',
|
||||
// },
|
||||
// scopes: kinds,
|
||||
// async findAccount(ctx, id): Promise<Account | undefined> {
|
||||
// console.log(id);
|
||||
// return undefined;
|
||||
// },
|
||||
// adapter(): MisskeyAdapter {
|
||||
// return new MisskeyAdapter(redisClient, httpRequestService);
|
||||
// },
|
||||
// async renderError(ctx, out, error): Promise<void> {
|
||||
// console.log(error);
|
||||
// },
|
||||
// });
|
||||
|
||||
// XXX: But MemoryKVCache just grows forever without being cleared if grant codes are left unused
|
||||
const grantCodeCache = new MemoryKVCache<{
|
||||
clientId: string,
|
||||
|
@ -438,8 +293,6 @@ export class OAuth2ProviderService {
|
|||
});
|
||||
fastify.post('/oauth/decision', async () => { });
|
||||
fastify.post('/oauth/token', async () => { });
|
||||
// fastify.get('/oauth/interaction/:uid', async () => { });
|
||||
// fastify.get('/oauth/interaction/:uid/login', async () => { });
|
||||
|
||||
fastify.register(fastifyView, {
|
||||
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||
|
@ -451,8 +304,6 @@ export class OAuth2ProviderService {
|
|||
});
|
||||
|
||||
await fastify.register(fastifyExpress);
|
||||
// TODO: use redis session store to prevent memory leak
|
||||
fastify.use(expressSession({ secret: 'keyboard cat', resave: false, saveUninitialized: false }) as any);
|
||||
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
|
||||
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
||||
console.log('HIT /oauth/authorize validation middleware', areq);
|
||||
|
@ -497,7 +348,6 @@ export class OAuth2ProviderService {
|
|||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1
|
||||
// But make sure not to redirect to an invalid redirect_uri
|
||||
fastify.use('/oauth/authorize', this.#server.errorHandler());
|
||||
// for (const middleware of this.#server.decision()) {
|
||||
|
||||
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
|
||||
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
|
||||
|
@ -512,8 +362,5 @@ export class OAuth2ProviderService {
|
|||
fastify.use('/oauth/token', bodyParser.json({ strict: true }));
|
||||
fastify.use('/oauth/token', this.#server.token());
|
||||
fastify.use('/oauth/token', this.#server.errorHandler());
|
||||
// }
|
||||
|
||||
// fastify.use('/oauth', this.#provider.callback());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@ function getMeta(html: string): { transactionId: string | undefined, clientName:
|
|||
};
|
||||
}
|
||||
|
||||
function fetchDecision(cookie: string, transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||
function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||
return fetch(new URL('/oauth/decision', host), {
|
||||
method: 'post',
|
||||
body: new URLSearchParams({
|
||||
|
@ -67,16 +67,14 @@ function fetchDecision(cookie: string, transactionId: string, user: misskey.enti
|
|||
redirect: 'manual',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
const { transactionId } = getMeta(await response.text());
|
||||
|
||||
return await fetchDecision(cookie!, transactionId!, user, { cancel });
|
||||
return await fetchDecision(transactionId!, user, { cancel });
|
||||
}
|
||||
|
||||
describe('OAuth', () => {
|
||||
|
@ -126,14 +124,12 @@ describe('OAuth', () => {
|
|||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
assert.ok(cookie?.startsWith('connect.sid='));
|
||||
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(typeof meta.transactionId, 'string');
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
|
||||
const decisionResponse = await fetchDecision(cookie!, meta.transactionId!, alice);
|
||||
const decisionResponse = await fetchDecision(meta.transactionId!, alice);
|
||||
assert.strictEqual(decisionResponse.status, 302);
|
||||
assert.ok(decisionResponse.headers.has('location'));
|
||||
|
||||
|
|
Loading…
Reference in a new issue