feat(auth): token-store CRUD on oauth_accounts (encrypted, 11 tests)
upsert / list / getDefault / setDefault / getAccessToken / getRefreshToken / delete. UNIQUE(provider, twitch_user_id) via ON CONFLICT DO UPDATE. setDefault ist provider-scoped (genau ein default pro provider). Scopes als JSON-Array serialisiert. 126 unit tests gruen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1eacf31f2
commit
d82ab3c31a
120
src/main/domain/token-store.test.ts
Normal file
120
src/main/domain/token-store.test.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { test, expect, describe, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { openDatabase, type DbHandle } from '../infra/db';
|
||||||
|
import { MemorySecureStorage } from '../infra/secure-storage';
|
||||||
|
import { createTokenStore, type TokenStore } from './token-store';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let db: DbHandle;
|
||||||
|
let store: TokenStore;
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokens-'));
|
||||||
|
db = openDatabase(path.join(tmpDir, 'app.db'));
|
||||||
|
store = createTokenStore(db, new MemorySecureStorage());
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
db.close();
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTokenStore', () => {
|
||||||
|
test('upsert new account returns record with id > 0', () => {
|
||||||
|
const rec = store.upsert({
|
||||||
|
provider: 'twitch',
|
||||||
|
twitchUserId: 'u1',
|
||||||
|
login: 'alice',
|
||||||
|
accessToken: 'aaa.aaa.aaa',
|
||||||
|
});
|
||||||
|
expect(rec.id).toBeGreaterThan(0);
|
||||||
|
expect(rec.login).toBe('alice');
|
||||||
|
expect(rec.provider).toBe('twitch');
|
||||||
|
expect(rec.twitchUserId).toBe('u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert same (provider, twitch_user_id) updates, no duplicate row', () => {
|
||||||
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice', accessToken: 't1' });
|
||||||
|
const updated = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'alice2', accessToken: 't2' });
|
||||||
|
expect(updated.login).toBe('alice2');
|
||||||
|
const all = store.list('twitch');
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].login).toBe('alice2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list() returns all accounts, list(provider) filters', () => {
|
||||||
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||||
|
store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||||
|
store.upsert({ provider: 'youtube', twitchUserId: undefined, login: 'c', accessToken: 'z' });
|
||||||
|
expect(store.list()).toHaveLength(3);
|
||||||
|
expect(store.list('twitch')).toHaveLength(2);
|
||||||
|
expect(store.list('youtube')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDefault returns null when nothing default', () => {
|
||||||
|
store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||||
|
expect(store.getDefault('twitch')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upsert with isDefault=true makes it default, demotes siblings', () => {
|
||||||
|
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||||
|
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y', isDefault: true });
|
||||||
|
|
||||||
|
const def = store.getDefault('twitch');
|
||||||
|
expect(def?.id).toBe(b.id);
|
||||||
|
|
||||||
|
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||||
|
expect(aAgain?.isDefault).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDefault toggles is_default exclusivity within provider', () => {
|
||||||
|
const a = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x', isDefault: true });
|
||||||
|
const b = store.upsert({ provider: 'twitch', twitchUserId: 'u2', login: 'b', accessToken: 'y' });
|
||||||
|
|
||||||
|
store.setDefault(b.id);
|
||||||
|
expect(store.getDefault('twitch')?.id).toBe(b.id);
|
||||||
|
|
||||||
|
const aAgain = store.list('twitch').find(r => r.id === a.id);
|
||||||
|
expect(aAgain?.isDefault).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAccessToken returns decrypted plaintext', () => {
|
||||||
|
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'super-secret-token' });
|
||||||
|
expect(store.getAccessToken(rec.id)).toBe('super-secret-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRefreshToken returns null if not provided, value if provided', () => {
|
||||||
|
const noRefresh = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 't1' });
|
||||||
|
expect(store.getRefreshToken(noRefresh.id)).toBeNull();
|
||||||
|
|
||||||
|
const withRefresh = store.upsert({
|
||||||
|
provider: 'twitch', twitchUserId: 'u2', login: 'b',
|
||||||
|
accessToken: 't2', refreshToken: 'refresh-xyz',
|
||||||
|
});
|
||||||
|
expect(store.getRefreshToken(withRefresh.id)).toBe('refresh-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes roundtrip as array', () => {
|
||||||
|
const rec = store.upsert({
|
||||||
|
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||||
|
accessToken: 't', scopes: ['user:read:email', 'channel:read:subscriptions'],
|
||||||
|
});
|
||||||
|
expect(rec.scopes).toEqual(['user:read:email', 'channel:read:subscriptions']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes the record', () => {
|
||||||
|
const rec = store.upsert({ provider: 'twitch', twitchUserId: 'u1', login: 'a', accessToken: 'x' });
|
||||||
|
store.delete(rec.id);
|
||||||
|
expect(store.list('twitch')).toHaveLength(0);
|
||||||
|
expect(() => store.getAccessToken(rec.id)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiresAt roundtrip', () => {
|
||||||
|
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
const rec = store.upsert({
|
||||||
|
provider: 'twitch', twitchUserId: 'u1', login: 'a',
|
||||||
|
accessToken: 't', expiresAt: future,
|
||||||
|
});
|
||||||
|
expect(rec.expiresAt).toBe(future);
|
||||||
|
});
|
||||||
|
});
|
||||||
203
src/main/domain/token-store.ts
Normal file
203
src/main/domain/token-store.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import type { DbHandle } from '../infra/db';
|
||||||
|
import type { SecureStorage } from '../infra/secure-storage';
|
||||||
|
|
||||||
|
export interface TokenRecord {
|
||||||
|
id: number;
|
||||||
|
provider: string;
|
||||||
|
twitchUserId: string | null;
|
||||||
|
login: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
expiresAt: number | null;
|
||||||
|
scopes: string[];
|
||||||
|
isDefault: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenWriteInput {
|
||||||
|
provider: string;
|
||||||
|
twitchUserId?: string;
|
||||||
|
login?: string;
|
||||||
|
displayName?: string;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
scopes?: string[];
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenStore {
|
||||||
|
upsert(input: TokenWriteInput): TokenRecord;
|
||||||
|
list(provider?: string): TokenRecord[];
|
||||||
|
getDefault(provider: string): TokenRecord | null;
|
||||||
|
setDefault(id: number): void;
|
||||||
|
getAccessToken(id: number): string;
|
||||||
|
getRefreshToken(id: number): string | null;
|
||||||
|
delete(id: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenRow {
|
||||||
|
id: number;
|
||||||
|
provider: string;
|
||||||
|
twitch_user_id: string | null;
|
||||||
|
login: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
encrypted_access_token: string;
|
||||||
|
encrypted_refresh_token: string | null;
|
||||||
|
expires_at: number | null;
|
||||||
|
scopes_json: string | null;
|
||||||
|
is_default: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToRecord(row: TokenRow): TokenRecord {
|
||||||
|
let scopes: string[] = [];
|
||||||
|
if (row.scopes_json) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.scopes_json);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
scopes = parsed.filter((s): s is string => typeof s === 'string');
|
||||||
|
}
|
||||||
|
} catch { /* malformed scopes payload — treat as empty */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
provider: row.provider,
|
||||||
|
twitchUserId: row.twitch_user_id,
|
||||||
|
login: row.login,
|
||||||
|
displayName: row.display_name,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
scopes,
|
||||||
|
isDefault: row.is_default === 1,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTokenStore(db: DbHandle, storage: SecureStorage): TokenStore {
|
||||||
|
function getRowOrThrow(id: number): TokenRow {
|
||||||
|
const row = db.get<TokenRow>('SELECT * FROM oauth_accounts WHERE id = ?', [id]);
|
||||||
|
if (!row) throw new Error(`token-store: account id=${id} not found`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upsert(input: TokenWriteInput): TokenRecord {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const encryptedAccess = storage.encrypt(input.accessToken);
|
||||||
|
const encryptedRefresh = input.refreshToken !== undefined
|
||||||
|
? storage.encrypt(input.refreshToken)
|
||||||
|
: null;
|
||||||
|
const scopesJson = input.scopes && input.scopes.length > 0
|
||||||
|
? JSON.stringify(input.scopes)
|
||||||
|
: null;
|
||||||
|
const isDefault = input.isDefault ? 1 : 0;
|
||||||
|
const twitchUserId = input.twitchUserId ?? null;
|
||||||
|
|
||||||
|
let resultId: number | null = null;
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
// Insert or update conditional on UNIQUE(provider, twitch_user_id).
|
||||||
|
// Sqlite's ON CONFLICT braucht den vollstaendigen Konflikt-Ausdruck.
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO oauth_accounts(
|
||||||
|
provider, twitch_user_id, login, display_name,
|
||||||
|
encrypted_access_token, encrypted_refresh_token,
|
||||||
|
expires_at, scopes_json, is_default, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(provider, twitch_user_id) DO UPDATE SET
|
||||||
|
login = excluded.login,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
encrypted_access_token = excluded.encrypted_access_token,
|
||||||
|
encrypted_refresh_token = excluded.encrypted_refresh_token,
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
scopes_json = excluded.scopes_json,
|
||||||
|
is_default = excluded.is_default,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
[
|
||||||
|
input.provider,
|
||||||
|
twitchUserId,
|
||||||
|
input.login ?? null,
|
||||||
|
input.displayName ?? null,
|
||||||
|
encryptedAccess,
|
||||||
|
encryptedRefresh,
|
||||||
|
input.expiresAt ?? null,
|
||||||
|
scopesJson,
|
||||||
|
isDefault,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wenn dieser Eintrag default ist: alle anderen mit gleichem provider auf 0 setzen.
|
||||||
|
if (isDefault === 1) {
|
||||||
|
db.run(
|
||||||
|
`UPDATE oauth_accounts
|
||||||
|
SET is_default = 0, updated_at = ?
|
||||||
|
WHERE provider = ?
|
||||||
|
AND NOT (twitch_user_id IS ? AND provider IS ?)`,
|
||||||
|
[now, input.provider, twitchUserId, input.provider]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = db.get<{ id: number }>(
|
||||||
|
`SELECT id FROM oauth_accounts
|
||||||
|
WHERE provider = ?
|
||||||
|
AND (twitch_user_id IS ? OR (twitch_user_id IS NULL AND ? IS NULL))`,
|
||||||
|
[input.provider, twitchUserId, twitchUserId]
|
||||||
|
);
|
||||||
|
resultId = lookup?.id ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultId === null) throw new Error('token-store: upsert lookup failed');
|
||||||
|
return rowToRecord(getRowOrThrow(resultId));
|
||||||
|
},
|
||||||
|
|
||||||
|
list(provider?: string): TokenRecord[] {
|
||||||
|
const rows = provider
|
||||||
|
? db.all<TokenRow>('SELECT * FROM oauth_accounts WHERE provider = ? ORDER BY id', [provider])
|
||||||
|
: db.all<TokenRow>('SELECT * FROM oauth_accounts ORDER BY id');
|
||||||
|
return rows.map(rowToRecord);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefault(provider: string): TokenRecord | null {
|
||||||
|
const row = db.get<TokenRow>(
|
||||||
|
'SELECT * FROM oauth_accounts WHERE provider = ? AND is_default = 1 LIMIT 1',
|
||||||
|
[provider]
|
||||||
|
);
|
||||||
|
return row ? rowToRecord(row) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setDefault(id: number): void {
|
||||||
|
const target = getRowOrThrow(id);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
db.transaction(() => {
|
||||||
|
db.run(
|
||||||
|
'UPDATE oauth_accounts SET is_default = 0, updated_at = ? WHERE provider = ?',
|
||||||
|
[now, target.provider]
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
'UPDATE oauth_accounts SET is_default = 1, updated_at = ? WHERE id = ?',
|
||||||
|
[now, id]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getAccessToken(id: number): string {
|
||||||
|
const row = getRowOrThrow(id);
|
||||||
|
return storage.decrypt(row.encrypted_access_token);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRefreshToken(id: number): string | null {
|
||||||
|
const row = getRowOrThrow(id);
|
||||||
|
return row.encrypted_refresh_token
|
||||||
|
? storage.decrypt(row.encrypted_refresh_token)
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id: number): void {
|
||||||
|
db.run('DELETE FROM oauth_accounts WHERE id = ?', [id]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user