fix(auth): atomically exchange gateway tokens
This commit is contained in:
@@ -7,6 +7,7 @@ interface RedisClientLike {
|
|||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise<string | null>;
|
set(key: string, value: string, options?: { EX?: number; NX?: boolean }): Promise<string | null>;
|
||||||
del?(key: string): Promise<number>;
|
del?(key: string): Promise<number>;
|
||||||
|
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCESS_TOKEN_PREFIX = 'ga_';
|
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||||
@@ -16,6 +17,15 @@ const buildAccessKey = (profileName: string, token: string): string => `sammo:ga
|
|||||||
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
||||||
`sammo:game:gateway-used:${profileName}:${sessionId}`;
|
`sammo:game:gateway-used:${profileName}:${sessionId}`;
|
||||||
|
|
||||||
|
const ISSUE_FROM_GATEWAY_SCRIPT = `
|
||||||
|
if redis.call('EXISTS', KEYS[1]) == 1 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
redis.call('SET', KEYS[2], ARGV[1], 'EX', ARGV[2])
|
||||||
|
redis.call('SET', KEYS[1], '1', 'EX', ARGV[2])
|
||||||
|
return 1
|
||||||
|
`;
|
||||||
|
|
||||||
const resolveTtlSeconds = (expiresAt: string): number => {
|
const resolveTtlSeconds = (expiresAt: string): number => {
|
||||||
const parsed = parseISO(expiresAt);
|
const parsed = parseISO(expiresAt);
|
||||||
if (!isValid(parsed)) {
|
if (!isValid(parsed)) {
|
||||||
@@ -49,6 +59,32 @@ export class RedisAccessTokenStore {
|
|||||||
return { accessToken, expiresAt: payload.expiresAt };
|
return { accessToken, expiresAt: payload.expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async issueFromGateway(
|
||||||
|
payload: GameSessionTokenPayload
|
||||||
|
): Promise<{ accessToken: string; expiresAt: string } | null | 'ALREADY_USED'> {
|
||||||
|
const ttlSeconds = resolveTtlSeconds(payload.expiresAt);
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.client.eval) {
|
||||||
|
throw new Error('Redis client does not support atomic gateway token exchange.');
|
||||||
|
}
|
||||||
|
const accessToken = `${ACCESS_TOKEN_PREFIX}${randomUUID()}`;
|
||||||
|
const accessKey = buildAccessKey(this.profileName, accessToken);
|
||||||
|
const usedKey = buildGatewayUsedKey(this.profileName, payload.sessionId);
|
||||||
|
const result = await this.client.eval(ISSUE_FROM_GATEWAY_SCRIPT, {
|
||||||
|
keys: [usedKey, accessKey],
|
||||||
|
arguments: [JSON.stringify(payload), String(ttlSeconds)],
|
||||||
|
});
|
||||||
|
if (Number(result) === 0) {
|
||||||
|
return 'ALREADY_USED';
|
||||||
|
}
|
||||||
|
if (Number(result) !== 1) {
|
||||||
|
throw new Error('Unexpected Redis result while issuing an access token.');
|
||||||
|
}
|
||||||
|
return { accessToken, expiresAt: payload.expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
async get(accessToken: string): Promise<GameSessionTokenPayload | null> {
|
async get(accessToken: string): Promise<GameSessionTokenPayload | null> {
|
||||||
if (!RedisAccessTokenStore.isAccessToken(accessToken)) {
|
if (!RedisAccessTokenStore.isAccessToken(accessToken)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,13 +119,4 @@ export class RedisAccessTokenStore {
|
|||||||
const key = buildAccessKey(this.profileName, accessToken);
|
const key = buildAccessKey(this.profileName, accessToken);
|
||||||
return (await this.client.del(key)) > 0;
|
return (await this.client.del(key)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markGatewayTokenUsed(sessionId: string, ttlSeconds: number): Promise<boolean> {
|
|
||||||
if (ttlSeconds <= 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const key = buildGatewayUsedKey(this.profileName, sessionId);
|
|
||||||
const result = await this.client.set(key, '1', { NX: true, EX: ttlSeconds });
|
|
||||||
return result === 'OK';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,15 +87,13 @@ export const authRouter = router({
|
|||||||
await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt);
|
await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const used = await ctx.accessTokenStore.markGatewayTokenUsed(payload.sessionId, ttlSeconds);
|
const created = await ctx.accessTokenStore.issueFromGateway(payload);
|
||||||
if (!used) {
|
if (created === 'ALREADY_USED') {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'CONFLICT',
|
code: 'CONFLICT',
|
||||||
message: 'Gateway token already used.',
|
message: 'Gateway token already used.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await ctx.accessTokenStore.create(payload);
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
|
|
||||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||||
|
|
||||||
describe('RedisAccessTokenStore.revoke', () => {
|
describe('RedisAccessTokenStore.revoke', () => {
|
||||||
@@ -33,3 +35,69 @@ describe('RedisAccessTokenStore.revoke', () => {
|
|||||||
expect(del).not.toHaveBeenCalled();
|
expect(del).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RedisAccessTokenStore.issueFromGateway', () => {
|
||||||
|
const payload = {
|
||||||
|
sessionId: 'gateway-session-1',
|
||||||
|
profile: 'che:default',
|
||||||
|
issuedAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
expiresAt: '2099-01-01T01:00:00.000Z',
|
||||||
|
user: { id: 'user-1', username: 'tester' },
|
||||||
|
roles: [],
|
||||||
|
sanctions: [],
|
||||||
|
} as unknown as GameSessionTokenPayload;
|
||||||
|
|
||||||
|
it('issues the access key and consumes the gateway token in one Redis evaluation', async () => {
|
||||||
|
const evalCommand = vi.fn(async (_script: string, _options: { keys: string[]; arguments: string[] }) => 1);
|
||||||
|
const store = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
eval: evalCommand,
|
||||||
|
},
|
||||||
|
'che:default'
|
||||||
|
);
|
||||||
|
|
||||||
|
const issued = await store.issueFromGateway(payload);
|
||||||
|
|
||||||
|
expect(issued).toMatchObject({ expiresAt: payload.expiresAt });
|
||||||
|
expect(issued && issued !== 'ALREADY_USED' ? issued.accessToken : '').toMatch(/^ga_/);
|
||||||
|
expect(evalCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(evalCommand.mock.calls[0]?.[1].keys[0]).toBe('sammo:game:gateway-used:che:default:gateway-session-1');
|
||||||
|
expect(evalCommand.mock.calls[0]?.[1].keys[1]).toBe(
|
||||||
|
`sammo:game:access:che:default:${issued && issued !== 'ALREADY_USED' ? issued.accessToken : ''}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can retry after an atomic Redis evaluation fails before commit', async () => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
let attempts = 0;
|
||||||
|
const evalCommand = vi.fn(async (_script: string, options: { keys: string[] }) => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts === 1) {
|
||||||
|
throw new Error('injected Redis failure');
|
||||||
|
}
|
||||||
|
if (keys.has(options.keys[0] ?? '')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
keys.add(options.keys[0] ?? '');
|
||||||
|
keys.add(options.keys[1] ?? '');
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
const store = new RedisAccessTokenStore(
|
||||||
|
{
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => null,
|
||||||
|
eval: evalCommand,
|
||||||
|
},
|
||||||
|
'che:default'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(store.issueFromGateway(payload)).rejects.toThrow('injected Redis failure');
|
||||||
|
expect(keys.size).toBe(0);
|
||||||
|
await expect(store.issueFromGateway(payload)).resolves.toMatchObject({ expiresAt: payload.expiresAt });
|
||||||
|
expect([...keys].filter((key) => key.includes(':access:'))).toHaveLength(1);
|
||||||
|
expect([...keys].filter((key) => key.includes(':gateway-used:'))).toHaveLength(1);
|
||||||
|
await expect(store.issueFromGateway(payload)).resolves.toBe('ALREADY_USED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -309,8 +309,7 @@ describe('appRouter', () => {
|
|||||||
throw new Error('ordinary exchange must not read the icon source');
|
throw new Error('ordinary exchange must not read the icon source');
|
||||||
});
|
});
|
||||||
const accessTokenStore = {
|
const accessTokenStore = {
|
||||||
markGatewayTokenUsed: vi.fn(async () => true),
|
issueFromGateway: vi.fn(async () => ({
|
||||||
create: vi.fn(async () => ({
|
|
||||||
accessToken: 'ga_access',
|
accessToken: 'ga_access',
|
||||||
expiresAt: '2099-01-01T01:00:00.000Z',
|
expiresAt: '2099-01-01T01:00:00.000Z',
|
||||||
})),
|
})),
|
||||||
@@ -342,14 +341,13 @@ describe('appRouter', () => {
|
|||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const revision = '2099-01-01T00:00:00.001Z';
|
const revision = '2099-01-01T00:00:00.001Z';
|
||||||
const accessTokenStore = {
|
const accessTokenStore = {
|
||||||
markGatewayTokenUsed: vi.fn(async () => {
|
issueFromGateway: vi.fn(async () => {
|
||||||
calls.push('mark-used');
|
calls.push('mark-used');
|
||||||
return true;
|
return {
|
||||||
|
accessToken: 'ga_access',
|
||||||
|
expiresAt: '2099-01-01T01:00:00.000Z',
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
create: vi.fn(async () => ({
|
|
||||||
accessToken: 'ga_access',
|
|
||||||
expiresAt: '2099-01-01T01:00:00.000Z',
|
|
||||||
})),
|
|
||||||
} as unknown as RedisAccessTokenStore;
|
} as unknown as RedisAccessTokenStore;
|
||||||
const payload = buildAuth();
|
const payload = buildAuth();
|
||||||
payload.issuedAt = '2099-01-01T00:00:00.000Z';
|
payload.issuedAt = '2099-01-01T00:00:00.000Z';
|
||||||
|
|||||||
Reference in New Issue
Block a user