Compare commits

...
Author SHA1 Message Date
Claude 9c8c761529 fix(auth): environment-scoped session cookies and resilient /auth/me refresh
Apps behind an nginx auth_request (Orchest / Intelligence / Process) rely on
the ddf-* cookies and on GET /auth/me. Several problems made them lose the
session or show 500 pages:

- Cookies were written with a hard-coded Domain=.dadosfera.ai in every
  environment, so a staging login overwrote the production session (and
  vice versa) in the same browser. Names now take AUTH_COOKIE_SUFFIX and the
  domain comes from COOKIE_DOMAIN (staging: -stg / .stg.dadosfera.ai).
- maxAge was the token's absolute epoch (~56 years). It is now a real
  duration, SESSION_COOKIE_MAX_AGE_DAYS (default 30).
- /auth/me answered 500 when a refresh failed for anything but
  NotAuthorizedException, when the refreshed token failed validation, or
  when DUC hung (no deadline). It now answers 401 and expires the dead
  cookies for definitive failures, 503 for DUC transport errors/timeouts
  (AUTH_RPC_TIMEOUT_MS, default 10s), keeps 403 answers instead of masking
  them with a refresh, refreshes when only the refresh cookie is left, and
  stops logging request headers (they contain the cookies).
- /auth/refresh-access-token rewrites all three cookies from the session the
  frontend just proved valid, healing stale cookies from another login or
  environment. /auth/sign-out expires all three and always responds.

Helm values wire the new variables for prd and stg.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUEXmydTBX9G2CGJ97YQKL
2026-09-03 13:13:54 +00:00
marcos.rodrigues 740540d4dc Merge pull request #517 from dadosfera/bugfix/sync-proto
FIX: sync proto
2026-08-28 13:33:27 -03:00
8 changed files with 457 additions and 103 deletions
@@ -101,6 +101,17 @@ spec:
value: {{ .Values.maestro.dedicated_proxy }}
- name: COOKIE_SECRET
value: {{ .Values.maestro.cookie_secret }}
# Browser session cookies (ddf-auth*, ddf-refresh-auth*, ddf-user-id*).
# Scoped per environment so a staging login never overwrites a
# production session in the same browser (both live under dadosfera.ai).
- name: COOKIE_DOMAIN
value: {{ .Values.maestro.cookie_domain | default ".dadosfera.ai" | quote }}
- name: AUTH_COOKIE_SUFFIX
value: {{ .Values.maestro.auth_cookie_suffix | default "" | quote }}
- name: SESSION_COOKIE_MAX_AGE_DAYS
value: {{ .Values.maestro.session_cookie_max_age_days | default "30" | quote }}
- name: AUTH_RPC_TIMEOUT_MS
value: {{ .Values.maestro.auth_rpc_timeout_ms | default "10000" | quote }}
- name: REDIS_DATABASE
value: "{{ .Values.maestro.redis_database }}"
- name: REDIS_HOST
+6
View File
@@ -7,6 +7,12 @@ maestro:
open_customer_id: b3e3dfe5-b992-4586-a73c-c0b0c00f615d
open_group_id: e3f98a2f-7748-4981-8505-7695c8ca8218
cookie_secret: "ff7bc13823edb2ae50d248e5780bddc9d4b31c36"
# Session cookies: staging gets its own names AND domain so that
# app.stg.dadosfera.ai and app.dadosfera.ai sessions never collide.
cookie_domain: ".stg.dadosfera.ai"
auth_cookie_suffix: "-stg"
session_cookie_max_age_days: "30"
auth_rpc_timeout_ms: "10000"
redis_database: "1"
platform_api_url: https://xs2hkhq07k.execute-api.us-east-1.amazonaws.com
connections_api_url: https://iy40eans64.execute-api.us-east-1.amazonaws.com
+5
View File
@@ -55,6 +55,11 @@ maestro:
redis_database: "0"
redis_tls: "true"
cookie_secret: "13cc5e136d3074bcc05bec8697092ec1f5f376bf"
# Session cookies: production keeps the historical names on the apex domain.
cookie_domain: ".dadosfera.ai"
auth_cookie_suffix: ""
session_cookie_max_age_days: "30"
auth_rpc_timeout_ms: "10000"
firebase_base_url: https://feature-flag-25bf6-default-rtdb.firebaseio.com/prd
connections_api_url: https://wv6fw7ayj4.execute-api.us-east-1.amazonaws.com
+42 -1
View File
@@ -35,4 +35,45 @@ So in order to have an SSO login, besides configuring the Strategy, we must have
// ... Rest of the logic
return { url: url.href };
}
```
```
## Browser session cookies
Besides the tokens returned in the sign-in body (kept by the frontend in
`localStorage` and sent as `Authorization`), Maestro also persists the session
as httpOnly cookies so that apps which cannot hold a token themselves —
Orchest / Intelligence / Process behind an nginx `auth_request` pointing at
`GET /auth/me` — can authenticate the browser.
| Cookie | Content |
| --- | --- |
| `ddf-auth<suffix>` | DUC access token |
| `ddf-refresh-auth<suffix>` | IdP refresh token |
| `ddf-user-id<suffix>` | DUC user id |
All three are `HttpOnly; Secure; SameSite=None; Path=/` and live for
`SESSION_COOKIE_MAX_AGE_DAYS` (default 30 — keep it aligned with the IdP
refresh-token validity). See `session-cookies.ts`.
They are **environment-scoped** so that a login on `app.stg.dadosfera.ai`
never overwrites a production session in the same browser:
| Env | `AUTH_COOKIE_SUFFIX` | `COOKIE_DOMAIN` |
| --- | --- | --- |
| prd | `""` | `.dadosfera.ai` |
| stg | `-stg` | `.stg.dadosfera.ai` |
`GET /auth/me` behaviour:
- valid access token → `200` + user (permissions are the raw seqids from the JWT);
- expired/invalid access token but refresh token present → refresh, rewrite the
cookies (`Set-Cookie`, access token first — nginx `auth_request` only relays
the first one) and `200`;
- refresh impossible (expired/revoked/foreign token, unknown user) → `401` and the
cookies are expired so the browser stops presenting a dead session;
- DUC unreachable / slower than `AUTH_RPC_TIMEOUT_MS` (default 10s) → `503`,
cookies untouched (transient, retry);
- resource belongs to another customer → `403`; new terms of use pending → `403`.
`POST /auth/refresh-access-token` re-writes all three cookies from the session
the frontend is actually using, and `POST /auth/sign-out` expires them.
+119 -33
View File
@@ -14,6 +14,8 @@ import {
Param,
Res,
UnauthorizedException,
HttpException,
ServiceUnavailableException,
} from '@nestjs/common';
import {
ApiHeaders,
@@ -51,7 +53,10 @@ import {
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { status as GrpcStatus } from '@grpc/grpc-js';
import ErrorCodes, { OauthErrors } from 'src/utils/errorCodes';
import ErrorBuilder from 'src/utils/ErrorBuilder';
import { clearSessionCookies, readAuthSession } from './session-cookies';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { LanguageEnum } from 'src/utils/languages.enum';
import { Language } from 'src/decorators/language.decorator';
@@ -129,14 +134,16 @@ export class AuthController {
) {
try {
this.logger.info('/auth - SignOut');
this.authClient.cleanUpAuthSession(res);
return res.send();
this.authClient.cleanUpAuthSession(res);
} catch (error) {
this.logger.error('/auth - SignIn - ERROR', error);
this.logger.error('/auth - SignOut - ERROR', error);
}
// Always answer: a sign-out that hangs leaves the frontend logout
// waiting on a request that never completes.
return res.send();
}
@Post('refresh-access-token')
@@ -149,7 +156,7 @@ export class AuthController {
@Res() res: Response,
) {
this.logger.info('/auth - RefreshAccessToken');
const frontHost = origin.replace(/^https?:\/\//, '');
const frontHost = (origin || '').replace(/^https?:\/\//, '');
const { refreshToken, userId } = body;
const metadata = PackTheMetadata({
@@ -159,10 +166,14 @@ export class AuthController {
const data = await this.authClient.refreshAccessToken({ refreshToken, userId }, metadata);
// Re-sync ALL session cookies with the session the frontend is actually
// using. The IdP does not rotate refresh tokens (DUC returns an empty
// refreshToken), so fall back to the one that just proved valid — this
// heals cookies left behind by an older login or by another environment.
this.authClient.writeAuthSession(res, {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
userId
refreshToken: data.refreshToken || refreshToken,
userId,
});
return res.send(data);
@@ -478,14 +489,26 @@ export class AuthController {
@Get('me')
async getMe(@Req() req: Request, @Res() res: Response) {
this.logger.info('GET /auth/me ')
this.logger.info(JSON.stringify(req.headers));
const resourceHost = (req.headers['x-original-url'] as string) || '';
// Never dump req.headers here: it contains the session cookies.
this.logger.info('GET /auth/me', { resourceHost });
// Check for API key header first
const apiKey = req.get('X-Api-key');
if (apiKey) {
this.logger.info('Authenticating via X-Api-key header');
const { api_key } = await this.apiKeyService.get(apiKey);
let api_key: Awaited<ReturnType<ApiKeyService['get']>>['api_key'];
try {
({ api_key } = await this.apiKeyService.get(apiKey));
} catch (error) {
if (this.isTransportError(error)) {
throw this.identityProviderUnavailable(error);
}
this.logger.error('/auth/me - invalid API key', {
error: error?.message,
});
throw new UnauthorizedException('Invalid API key');
}
const userDto: UserDTO = {
id: api_key.user_id,
@@ -502,35 +525,98 @@ export class AuthController {
return res.status(200).json(userDto);
}
// Get token and headers
const accessToken = req.cookies['ddf-auth'];
const refreshToken = req.cookies['ddf-refresh-auth'];
const userId = req.cookies['ddf-user-id'];
const resourceHost = req.headers["x-original-url"] as string || "" ;
const { accessToken, refreshToken, userId } = readAuthSession(req);
const canRefresh = Boolean(refreshToken) && Boolean(userId);
this.logger.info('Has User Session: ' + Boolean(accessToken || canRefresh));
const hasUserSession = Boolean(accessToken) && Boolean(userId);
this.logger.info('Has User Session: ' + hasUserSession);
if (!accessToken && !canRefresh) {
throw new UnauthorizedException();
}
if (!hasUserSession) {
throw new UnauthorizedException()
if (accessToken) {
try {
const userDto = await this.authClient.validateUserSession(
accessToken,
resourceHost,
);
return res.status(200).json(userDto);
} catch (error) {
// Definitive answers (e.g. 403: resource belongs to another
// customer) must not be masked by a refresh attempt.
if (error instanceof HttpException) throw error;
if (this.isTransportError(error)) {
throw this.identityProviderUnavailable(error);
}
if (!canRefresh) {
this.logger.error(
'/auth/me - access token rejected and no refresh token',
{ error: error?.message },
);
clearSessionCookies(res);
throw new ErrorBuilder(ErrorCodes.AUTH.INVALID_REFRESH_TOKEN);
}
this.logger.info('/auth/me - access token rejected, refreshing', {
error: error?.message,
});
}
}
try {
const userDto = await this.authClient.validateUserSession(accessToken, resourceHost);
return res.status(200).json(userDto);
} catch (error) {
if (!refreshToken) {
this.logger.error('Invalid refresh token or customer name');
throw new UnauthorizedException("Invalid refresh token or customer name");
};
const {
authSession,
user
} = await this.authClient.refreshUserSession(refreshToken, userId, resourceHost);
this.authClient.writeAuthSession(res, authSession);
const { authSession, user } = await this.authClient.refreshUserSession(
refreshToken,
userId,
resourceHost,
);
// Refresh tokens are not rotated by the IdP (DUC returns ''), so keep
// the one that just worked; this also slides the cookie lifetime.
this.authClient.writeAuthSession(res, {
...authSession,
refreshToken: authSession.refreshToken || refreshToken,
});
return res.status(200).json(user);
} catch (error) {
if (error instanceof HttpException) throw error;
if (this.isTransportError(error)) {
throw this.identityProviderUnavailable(error);
}
const code =
typeof error?.details === 'string' ? error.details : undefined;
if (code === ErrorCodes.TERMS_OF_USE.REQUIRED) {
// The session is fine; the user has to accept the new terms first.
throw new ErrorBuilder(code);
}
// Expired, revoked, or minted by another environment / user pool.
// Answer 401 (never 500) and drop the dead cookies so the browser
// does not keep presenting them on every request.
this.logger.error('/auth/me - session refresh failed', {
code,
error: error?.message,
});
clearSessionCookies(res);
throw new ErrorBuilder(ErrorCodes.AUTH.INVALID_REFRESH_TOKEN);
}
}
/** DUC unreachable or too slow — a transient failure, not an auth answer. */
private isTransportError(error: any): boolean {
return (
error?.name === 'TimeoutError' ||
error?.code === GrpcStatus.UNAVAILABLE ||
error?.code === GrpcStatus.DEADLINE_EXCEEDED
);
}
private identityProviderUnavailable(error: any): HttpException {
this.logger.error('/auth/me - identity provider unavailable', {
code: error?.code,
error: error?.message,
});
// 503 (not 500): callers such as nginx auth_request / Orchest's
// auth-server treat it as "retry later" instead of a broken session.
return new ServiceUnavailableException(
'Identity provider temporarily unavailable',
);
}
}
+40 -69
View File
@@ -8,7 +8,7 @@ import {
} from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { DadosferaLogger } from '@dadosfera/dadosfera-logs';
import { lastValueFrom } from 'rxjs';
import { lastValueFrom, timeout } from 'rxjs';
import { ProtoServices } from '@dadosfera/protospack-v2/dist/lib/Duc';
import {
@@ -35,12 +35,24 @@ import { BulkEditResponse, UserDTO } from './dtos/login';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { PackTheMetadata } from 'src/utils/PackTheMetadata';
import { Request, Response } from 'express';
import {
AuthSession,
clearSessionCookies,
writeSessionCookies,
} from './session-cookies';
type AuthSession = {
accessToken?: string;
refreshToken?: string;
userId?: string;
};
export type { AuthSession };
/**
* Upper bound for the DUC round-trips on the session-validation path
* (`GET /auth/me`). Without it a hung DUC keeps every `auth_request`
* subrequest open until the ingress gives up (minutes), which surfaces to
* users as 500/504 pages instead of a fast, retryable 503.
*/
function authRpcTimeoutMs(): number {
const ms = Number(process.env.AUTH_RPC_TIMEOUT_MS);
return Number.isFinite(ms) && ms > 0 ? ms : 10_000;
}
@Injectable()
export class AuthClientService implements OnModuleInit {
@@ -70,7 +82,9 @@ export class AuthClientService implements OnModuleInit {
async getPublicKeys() {
this.logger.info('GetPublicKeys');
return lastValueFrom(this.authService.AuthGetPublicKeys({}));
return lastValueFrom(
this.authService.AuthGetPublicKeys({}).pipe(timeout(authRpcTimeoutMs())),
);
}
async snowflakeSignIn(input: AuthSnowflakeSignInRequest) {
@@ -132,10 +146,9 @@ export class AuthClientService implements OnModuleInit {
this.logger.info('RefreshAccessToken');
return lastValueFrom(
this.authService.AuthRefreshAccessToken(
{ refreshToken, userId },
metadata,
),
this.authService
.AuthRefreshAccessToken({ refreshToken, userId }, metadata)
.pipe(timeout(authRpcTimeoutMs())),
);
}
@@ -346,67 +359,18 @@ export class AuthClientService implements OnModuleInit {
};
}
/**
* Persists the session in the browser as httpOnly cookies. Names, domain
* and lifetime are environment-scoped — see `session-cookies.ts`.
*/
public writeAuthSession(res: Response, data: AuthSession) {
let exp = 1000 * 60 * 5; // 5 minutes
if (data.accessToken) {
const { exp: expiration } = jwt.decode(data.accessToken) as JwtPayload;
exp = (expiration - 30) * 1000; // exp em segundos, maxAge em ms
this.logger.info('Set Cookie ddf-auth');
res.cookie('ddf-auth', data.accessToken, {
domain: '.dadosfera.ai',
maxAge: exp,
httpOnly: true,
secure: true,
sameSite: 'none', // Necessário para cookies em requisições cross-site
});
}
if (data.refreshToken) {
this.logger.info('Set Cookie ddf-refresh-auth');
res.cookie('ddf-refresh-auth', data.refreshToken, {
domain: '.dadosfera.ai',
maxAge: exp,
httpOnly: true,
secure: true,
sameSite: 'none', // Necessário para cookies em requisições cross-site
});
}
if (data.userId) {
this.logger.info('Set Cookie ddf-refresh-auth');
res.cookie('ddf-user-id', data.userId, {
domain: '.dadosfera.ai',
maxAge: exp,
httpOnly: true,
secure: true,
sameSite: 'none', // Necessário para cookies em requisições cross-site
});
}
this.logger.info('Set session cookies');
writeSessionCookies(res, data);
}
/** Expires every session cookie this environment writes. */
public cleanUpAuthSession(res: Response) {
const exp = 1000 * 60 * 3;
res.cookie('ddf-auth', '', {
domain: 'dadosfera.ai',
maxAge: Date.now() - exp,
expires: new Date(),
httpOnly: true,
secure: true,
sameSite: 'none', // Necessário para cookies em requisições cross-site
});
res.cookie('ddf-refresh-auth', '', {
domain: 'dadosfera.ai',
maxAge: Date.now() - exp,
expires: new Date(),
httpOnly: true,
secure: true,
sameSite: 'none', // Necessário para cookies em requisições cross-site
});
clearSessionCookies(res);
this.logger.info('Clean cookie sessions');
}
@@ -432,9 +396,16 @@ export class AuthClientService implements OnModuleInit {
});
const { user } = await lastValueFrom(
this.userService.UserFindOneById({ id: payload.user_id }, metadata),
this.userService
.UserFindOneById({ id: payload.user_id }, metadata)
.pipe(timeout(authRpcTimeoutMs())),
);
if (!user) {
// Deleted/unknown user: a definitive "no session", not a crash.
throw new HttpException('User not found', HttpStatus.UNAUTHORIZED);
}
const userDto: UserDTO = {
id: user.id,
name: user.name,
+103
View File
@@ -0,0 +1,103 @@
import {
clearSessionCookies,
readAuthSession,
sessionCookieMaxAgeMs,
sessionCookieNames,
sessionCookieOptions,
writeSessionCookies,
} from './session-cookies';
const DAY_MS = 24 * 60 * 60 * 1000;
function fakeResponse() {
const cookie = jest.fn();
const clearCookie = jest.fn();
return { cookie, clearCookie };
}
describe('session cookies', () => {
it('uses the legacy names and domain when nothing is configured (production)', () => {
const env = {};
expect(sessionCookieNames(env)).toEqual({
accessToken: 'ddf-auth',
refreshToken: 'ddf-refresh-auth',
userId: 'ddf-user-id',
});
expect(sessionCookieOptions(env)).toMatchObject({
domain: '.dadosfera.ai',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'none',
});
});
it('scopes names and domain per environment so staging never clobbers production', () => {
const env = { AUTH_COOKIE_SUFFIX: '-stg', COOKIE_DOMAIN: '.stg.dadosfera.ai' };
expect(sessionCookieNames(env)).toEqual({
accessToken: 'ddf-auth-stg',
refreshToken: 'ddf-refresh-auth-stg',
userId: 'ddf-user-id-stg',
});
expect(sessionCookieOptions(env).domain).toBe('.stg.dadosfera.ai');
});
it('treats maxAge as a duration (30 days by default), not as the token epoch', () => {
expect(sessionCookieMaxAgeMs({})).toBe(30 * DAY_MS);
expect(sessionCookieMaxAgeMs({ SESSION_COOKIE_MAX_AGE_DAYS: '7' })).toBe(7 * DAY_MS);
expect(sessionCookieMaxAgeMs({ SESSION_COOKIE_MAX_AGE_DAYS: 'nope' })).toBe(30 * DAY_MS);
expect(sessionCookieMaxAgeMs({ SESSION_COOKIE_MAX_AGE_DAYS: '-1' })).toBe(30 * DAY_MS);
});
it('reads the session using the environment-specific names', () => {
const env = { AUTH_COOKIE_SUFFIX: '-stg' };
const req = {
cookies: {
'ddf-auth': 'prod-token',
'ddf-auth-stg': 'stg-token',
'ddf-refresh-auth-stg': 'stg-refresh',
'ddf-user-id-stg': 'u-stg',
},
};
expect(readAuthSession(req as any, env)).toEqual({
accessToken: 'stg-token',
refreshToken: 'stg-refresh',
userId: 'u-stg',
});
expect(readAuthSession({ cookies: undefined } as any, env)).toEqual({
accessToken: undefined,
refreshToken: undefined,
userId: undefined,
});
});
it('writes the access-token cookie first and only the parts that are present', () => {
const res = fakeResponse();
writeSessionCookies(res as any, { accessToken: 'a', userId: 'u' }, {});
expect(res.cookie.mock.calls.map((c) => c[0])).toEqual(['ddf-auth', 'ddf-user-id']);
expect(res.cookie.mock.calls[0][2]).toMatchObject({
domain: '.dadosfera.ai',
path: '/',
maxAge: 30 * DAY_MS,
httpOnly: true,
secure: true,
sameSite: 'none',
});
});
it('clears all three cookies with the same domain and path they were set with', () => {
const res = fakeResponse();
const env = { AUTH_COOKIE_SUFFIX: '-stg', COOKIE_DOMAIN: '.stg.dadosfera.ai' };
clearSessionCookies(res as any, env);
expect(res.clearCookie.mock.calls.map((c) => c[0])).toEqual([
'ddf-auth-stg',
'ddf-refresh-auth-stg',
'ddf-user-id-stg',
]);
for (const call of res.clearCookie.mock.calls) {
expect(call[1]).toMatchObject({ domain: '.stg.dadosfera.ai', path: '/' });
}
});
});
+131
View File
@@ -0,0 +1,131 @@
import { CookieOptions, Request, Response } from 'express';
/**
* Browser session cookies written by Maestro and consumed by every app that
* authenticates through `GET /auth/me` (Orchest auth-server, nginx
* `auth_request` ingresses, ...).
*
* The names and the domain are environment-scoped so that a staging login
* never overwrites (or is confused with) a production session in the same
* browser: both platforms live under `dadosfera.ai`, so a cookie on the
* registrable domain is sent to *both* environments.
*
* ENV | AUTH_COOKIE_SUFFIX | COOKIE_DOMAIN
* prd | "" | .dadosfera.ai -> ddf-auth
* stg | "-stg" | .stg.dadosfera.ai -> ddf-auth-stg
*/
export type AuthSession = {
accessToken?: string;
refreshToken?: string;
userId?: string;
};
export type SessionCookieNames = {
accessToken: string;
refreshToken: string;
userId: string;
};
type Env = Record<string, string | undefined>;
const DEFAULT_COOKIE_DOMAIN = '.dadosfera.ai';
const DEFAULT_SESSION_COOKIE_MAX_AGE_DAYS = 30;
export function cookieDomain(env: Env = process.env): string {
return env.COOKIE_DOMAIN || DEFAULT_COOKIE_DOMAIN;
}
export function cookieSuffix(env: Env = process.env): string {
return env.AUTH_COOKIE_SUFFIX || '';
}
export function sessionCookieNames(env: Env = process.env): SessionCookieNames {
const suffix = cookieSuffix(env);
return {
accessToken: `ddf-auth${suffix}`,
refreshToken: `ddf-refresh-auth${suffix}`,
userId: `ddf-user-id${suffix}`,
};
}
/**
* How long the browser keeps the session cookies. This is a *duration*
* (express `maxAge` is in milliseconds from now), independent from the
* access token's own `exp`: an expired access token in the cookie is
* harmless — `/auth/me` transparently refreshes it — while a refresh token
* that outlives the cookie would make the browser forget a still-valid
* session. Keep it aligned with the IdP refresh-token validity.
*/
export function sessionCookieMaxAgeMs(env: Env = process.env): number {
const days = Number(env.SESSION_COOKIE_MAX_AGE_DAYS);
const safeDays =
Number.isFinite(days) && days > 0
? days
: DEFAULT_SESSION_COOKIE_MAX_AGE_DAYS;
return safeDays * 24 * 60 * 60 * 1000;
}
export function sessionCookieOptions(env: Env = process.env): CookieOptions {
return {
domain: cookieDomain(env),
path: '/',
httpOnly: true,
secure: true,
// Required: the cookies are sent cross-site (app.* -> maestro.*) and
// from embedded iframes (app.* embedding app-intelligence-*.*).
sameSite: 'none',
};
}
export function readAuthSession(
req: Pick<Request, 'cookies'>,
env: Env = process.env,
): AuthSession {
const names = sessionCookieNames(env);
const cookies = req.cookies ?? {};
return {
accessToken: cookies[names.accessToken] || undefined,
refreshToken: cookies[names.refreshToken] || undefined,
userId: cookies[names.userId] || undefined,
};
}
/**
* (Re)writes whichever parts of the session are present. The access-token
* cookie is written first on purpose: nginx `auth_request` relays only the
* first `Set-Cookie` of the auth subrequest (`$upstream_http_set_cookie`),
* and the access token is the one that changes on refresh.
*/
export function writeSessionCookies(
res: Pick<Response, 'cookie'>,
session: AuthSession,
env: Env = process.env,
): void {
const names = sessionCookieNames(env);
const options: CookieOptions = {
...sessionCookieOptions(env),
maxAge: sessionCookieMaxAgeMs(env),
};
if (session.accessToken) {
res.cookie(names.accessToken, session.accessToken, options);
}
if (session.refreshToken) {
res.cookie(names.refreshToken, session.refreshToken, options);
}
if (session.userId) {
res.cookie(names.userId, session.userId, options);
}
}
/** Expires all three session cookies (same name/domain/path they were set with). */
export function clearSessionCookies(
res: Pick<Response, 'clearCookie'>,
env: Env = process.env,
): void {
const names = sessionCookieNames(env);
const options = sessionCookieOptions(env);
res.clearCookie(names.accessToken, options);
res.clearCookie(names.refreshToken, options);
res.clearCookie(names.userId, options);
}