fix(auth): handle a missing AUTH_SECRET instead of dying mid sign-in

`storeTokens()` and `createSessionToken()` ran outside any catch. Both read
AUTH_SECRET — one derives the AES key that encrypts the platform bundle, the
other signs the identity cookie — and in production both refuse to fall back to
the development key. So an unset AUTH_SECRET threw after the credentials had
already been accepted upstream, and the browser got a bare 500 on a sign-in
that was entirely valid.

The status was the smaller half. The upstream session minted moments earlier by
`authApi.login` was ORPHANED: a live refresh token, issued to somebody who did
not end up logged in, left to expire on its own. The platform-admin branch a few
lines above already revokes for precisely this reason — declining because the
server is broken is no different from declining because the account is wrong —
so this now revokes too, best-effort, on the same terms.

It fails closed. No cookie is set on this path, so a half-configured server
cannot hand out a session it will be unable to verify on the next request.

ConfigError moves to src/shared/errors/configError.ts because its throwers now
span two runtimes: apiClient and tokenStore are server-only, while sessionToken
is reached from src/proxy.ts, which Next compiles for Edge. Declaring it in
apiClient would have dragged the whole platform client, `server-only` guard and
all, into the proxy bundle to name one class. The new module imports nothing.

Verified by exercising both functions directly: with AUTH_SECRET unset under
NODE_ENV=production, sealTokens and createSessionToken each raise ConfigError
rather than a bare Error; with it set, both succeed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 19:43:57 +05:30
parent 3dc0bba6f4
commit c01c750436
6 changed files with 99 additions and 36 deletions

View File

@@ -1,7 +1,8 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {ConfigError, UpstreamError} from '@/services/api/apiClient';
import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError} from '@/shared/errors/configError';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
@@ -215,20 +216,64 @@ export async function POST(req: NextRequest) {
);
}
await storeTokens(bundle);
/**
* Minting the local session, which is where AUTH_SECRET is first read.
*
* Both steps below need it — storeTokens ENCRYPTS the platform bundle with a
* key derived from it, createSessionToken SIGNS the identity cookie with it —
* and in production both refuse to fall back to the development key. They ran
* outside any catch, so an unset AUTH_SECRET surfaced as a bare 500 from a
* sign-in whose credentials were perfectly good, with nothing in the response
* to say which of the two required variables was missing.
*
* Worse than the status: the upstream session minted moments ago by
* `authApi.login` was ORPHANED. A live refresh token, issued to somebody who
* did not get logged in, left to expire on its own. The platform-admin branch
* above already revokes for exactly this reason; declining because the server
* is broken is no different from declining because the account is wrong.
*
* Fails closed: no cookie is set, so a half-configured server cannot hand out
* a session it is unable to verify on the next request.
*/
const user = toAuthUser(bundle.user);
const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS;
const sessionCookie = createSessionToken(
{
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
organisation: user.organisation,
},
maxAge,
);
let sessionCookie: string;
try {
await storeTokens(bundle);
sessionCookie = createSessionToken(
{
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
organisation: user.organisation,
},
maxAge,
);
} catch (err) {
if (!(err instanceof ConfigError)) throw err;
console.error('[loyaly] configuration error:', err.message);
try {
await authApi.logout(bundle.access_token);
} catch {
/* best-effort, exactly as in the platform-admin branch above */
}
const code: LoginErrorCode = 'misconfigured';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'Sign-in is unavailable right now. Please contact support.',
500,
);
}
const session: AuthSession = {user, expiresAt: bundle.expires_at};