mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31: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 };
|
||||
|
||||
Reference in New Issue
Block a user