mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)
* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID).
This commit is contained in:
@@ -43,7 +43,7 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
|
||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||
const inviteToken = req.query.invite as string | undefined;
|
||||
const state = createState(redirectUri, inviteToken);
|
||||
const { state, codeChallenge } = createState(redirectUri, inviteToken);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
@@ -51,6 +51,8 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
redirect_uri: redirectUri,
|
||||
scope: process.env.OIDC_SCOPE || 'openid email profile',
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
res.redirect(`${doc.authorization_endpoint}?${params}`);
|
||||
@@ -92,7 +94,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||
|
||||
const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret);
|
||||
const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier);
|
||||
if (!tokenData._ok || !tokenData.access_token) {
|
||||
console.error('[OIDC] Token exchange failed: status', tokenData._status);
|
||||
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
||||
|
||||
@@ -57,7 +57,7 @@ const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
|
||||
// State management – pending OIDC states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
|
||||
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -66,10 +66,19 @@ setInterval(() => {
|
||||
}
|
||||
}, STATE_CLEANUP);
|
||||
|
||||
export function createState(redirectUri: string, inviteToken?: string): string {
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// Creates the login state and a matching PKCE pair. The verifier stays server
|
||||
// side (in pendingStates); the S256 challenge goes to the provider so PKCE-
|
||||
// required setups (e.g. Pocket ID with PKCE = required) work.
|
||||
export function createState(redirectUri: string, inviteToken?: string): { state: string; codeChallenge: string } {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
||||
return state;
|
||||
const codeVerifier = base64url(crypto.randomBytes(32));
|
||||
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
|
||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier });
|
||||
return { state, codeChallenge };
|
||||
}
|
||||
|
||||
export function consumeState(state: string) {
|
||||
@@ -204,17 +213,20 @@ export async function exchangeCodeForToken(
|
||||
redirectUri: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
codeVerifier?: string,
|
||||
): Promise<OidcTokenResponse & { _ok: boolean; _status: number }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
});
|
||||
if (codeVerifier) body.set('code_verifier', codeVerifier);
|
||||
const tokenRes = await fetch(doc.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
body,
|
||||
});
|
||||
const tokenData = (await tokenRes.json()) as OidcTokenResponse;
|
||||
return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status };
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
});
|
||||
|
||||
// Create a valid state token
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
|
||||
|
||||
@@ -215,7 +215,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
@@ -258,7 +258,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'Alice',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'Blocked',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
|
||||
|
||||
@@ -83,17 +83,20 @@ afterAll(() => {
|
||||
// ── createState / consumeState ────────────────────────────────────────────────
|
||||
|
||||
describe('createState / consumeState', () => {
|
||||
it('OIDC-SVC-001: createState returns a hex token', () => {
|
||||
const state = createState('https://example.com/callback');
|
||||
it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => {
|
||||
const { state, codeChallenge } = createState('https://example.com/callback');
|
||||
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding
|
||||
});
|
||||
|
||||
it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => {
|
||||
const state = createState('https://example.com/callback', 'invite-abc');
|
||||
it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => {
|
||||
const { state } = createState('https://example.com/callback', 'invite-abc');
|
||||
const data = consumeState(state);
|
||||
expect(data).not.toBeNull();
|
||||
expect(data!.redirectUri).toBe('https://example.com/callback');
|
||||
expect(data!.inviteToken).toBe('invite-abc');
|
||||
expect(typeof data!.codeVerifier).toBe('string');
|
||||
expect(data!.codeVerifier.length).toBeGreaterThan(20);
|
||||
// State is consumed — second call returns null
|
||||
expect(consumeState(state)).toBeNull();
|
||||
});
|
||||
@@ -103,8 +106,8 @@ describe('createState / consumeState', () => {
|
||||
});
|
||||
|
||||
it('OIDC-SVC-004: two different states do not conflict', () => {
|
||||
const s1 = createState('http://a.example.com');
|
||||
const s2 = createState('http://b.example.com');
|
||||
const { state: s1 } = createState('http://a.example.com');
|
||||
const { state: s2 } = createState('http://b.example.com');
|
||||
expect(s1).not.toBe(s2);
|
||||
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
|
||||
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');
|
||||
|
||||
Reference in New Issue
Block a user