150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { parseJson } from '@sammo-ts/common';
|
|
|
|
export type OAuthMode = 'login' | 'change_pw';
|
|
|
|
export interface OAuthPendingState {
|
|
state: string;
|
|
mode: OAuthMode;
|
|
scopes: string[];
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface OAuthSession {
|
|
id: string;
|
|
mode: OAuthMode;
|
|
kakaoId: string;
|
|
email: string;
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
accessTokenValidUntil: string;
|
|
refreshTokenValidUntil?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface OAuthSessionStore {
|
|
createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState>;
|
|
consumePendingState(state: string): Promise<OAuthPendingState | null>;
|
|
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
|
|
consumeSession(sessionId: string): Promise<OAuthSession | null>;
|
|
}
|
|
|
|
interface RedisPipeline {
|
|
set(key: string, value: string, options?: { EX?: number }): RedisPipeline;
|
|
del(key: string): RedisPipeline;
|
|
exec(): Promise<unknown>;
|
|
}
|
|
|
|
interface RedisClientLike {
|
|
get(key: string): Promise<string | null>;
|
|
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
|
del(key: string): Promise<number>;
|
|
multi(): RedisPipeline;
|
|
}
|
|
|
|
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
|
private readonly client: RedisClientLike;
|
|
private readonly prefix: string;
|
|
private readonly ttlSeconds: number;
|
|
|
|
constructor(client: RedisClientLike, prefix: string, ttlSeconds: number) {
|
|
this.client = client;
|
|
this.prefix = prefix;
|
|
this.ttlSeconds = ttlSeconds;
|
|
}
|
|
|
|
private stateKey(state: string): string {
|
|
return `${this.prefix}:oauth-state:${state}`;
|
|
}
|
|
|
|
private sessionKey(sessionId: string): string {
|
|
return `${this.prefix}:oauth-session:${sessionId}`;
|
|
}
|
|
|
|
async createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState> {
|
|
const state: OAuthPendingState = {
|
|
state: randomUUID(),
|
|
mode,
|
|
scopes,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
await this.client.set(this.stateKey(state.state), JSON.stringify(state), {
|
|
EX: this.ttlSeconds,
|
|
});
|
|
return state;
|
|
}
|
|
|
|
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
|
const key = this.stateKey(state);
|
|
const raw = await this.client.get(key);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
await this.client.del(key);
|
|
return parseJson<OAuthPendingState>(raw);
|
|
}
|
|
|
|
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
|
const stored: OAuthSession = {
|
|
...session,
|
|
id: randomUUID(),
|
|
};
|
|
await this.client.set(this.sessionKey(stored.id), JSON.stringify(stored), {
|
|
EX: this.ttlSeconds,
|
|
});
|
|
return stored;
|
|
}
|
|
|
|
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
|
const key = this.sessionKey(sessionId);
|
|
const raw = await this.client.get(key);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
await this.client.del(key);
|
|
return parseJson<OAuthSession>(raw);
|
|
}
|
|
}
|
|
|
|
// 테스트용 인메모리 OAuth 세션 저장소.
|
|
export class InMemoryOAuthSessionStore implements OAuthSessionStore {
|
|
private readonly pendingStates = new Map<string, OAuthPendingState>();
|
|
private readonly sessions = new Map<string, OAuthSession>();
|
|
|
|
async createPendingState(mode: OAuthMode, scopes: string[]): Promise<OAuthPendingState> {
|
|
const pending: OAuthPendingState = {
|
|
state: randomUUID(),
|
|
mode,
|
|
scopes,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
this.pendingStates.set(pending.state, pending);
|
|
return pending;
|
|
}
|
|
|
|
async consumePendingState(state: string): Promise<OAuthPendingState | null> {
|
|
const pending = this.pendingStates.get(state) ?? null;
|
|
if (pending) {
|
|
this.pendingStates.delete(state);
|
|
}
|
|
return pending;
|
|
}
|
|
|
|
async createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession> {
|
|
const stored: OAuthSession = {
|
|
...session,
|
|
id: randomUUID(),
|
|
};
|
|
this.sessions.set(stored.id, stored);
|
|
return stored;
|
|
}
|
|
|
|
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
|
const session = this.sessions.get(sessionId) ?? null;
|
|
if (session) {
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
return session;
|
|
}
|
|
}
|