Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ When the generated client receives a status in `refreshOn`, it calls the wired r

The refresh callback re-invokes the custom strategy's `authenticate()` rather than re-bootstrapping `/session`. `settings.json` `auth.refreshOn` takes precedence over `sessionAuth.refreshOn` when both are set.

`.apijack/settings.json` only applies to consumers of the shared `apijack` binary; a project with its own `bin/<cli>.ts` sets the same option programmatically instead: `createCli({ refreshOn: [401], /* ... */ })`.

### Dropping base-strategy headers post-handshake (opt-in)

By default, `SessionAuthStrategy` mirrors the wrapped base strategy's headers (e.g. `Authorization: Basic …` from `BasicAuthStrategy`) onto every post-handshake API request. For stateful backends — Spring Security with 2FA, for instance — re-presenting the base credentials on every call re-triggers the auth filter and either re-prompts, 401s, or invalidates the active session. Opt in to `dropBaseHeaders` to strip them:
Expand All @@ -250,7 +252,7 @@ The `.apijack/` directory at a project root is auto-loaded when the CLI runs ins
| `.apijack/auth.ts` | Project-level `AuthStrategy` and optional `onChallenge` |
| `.apijack/plugins.ts` | Project-level plugin registrations (`default: ApijackPlugin[]` — each entry passed to `cli.use(...)`) |
| `.apijack/routines/*.yaml` | Routines available via `routine run <name>` |
| `.apijack/settings.json` | Framework defaults (see below) |
| `.apijack/settings.json` | Framework defaults (see below; also `auth.refreshOn` — see "Stale-session refresh and retry" above) |
| `.apijack/aliases.json` | Project-local command aliases (see below) |

### Command aliases
Expand Down
6 changes: 6 additions & 0 deletions src/auth/refresh-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,11 @@ export function resolveRefreshWiring(
const mergedSessionAuth = rawSessionAuth?.session?.endpoint ? rawSessionAuth : undefined;
const refreshOn = options.refreshOn ?? rawSessionAuth?.refreshOn;

if (rawSessionAuth && !mergedSessionAuth) {
console.warn(
'[apijack] sessionAuth is set but missing session.endpoint — SessionAuthStrategy will not be used.',
);
}

return { mergedSessionAuth, refreshOn };
}
53 changes: 48 additions & 5 deletions tests/auth/refresh-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, test, expect } from 'bun:test';
import { describe, test, expect, spyOn } from 'bun:test';
import { resolveRefreshWiring } from '../../src/auth/refresh-wiring';
import type { SessionAuthConfig } from '../../src/auth/types';

Expand Down Expand Up @@ -50,11 +50,17 @@ describe('resolveRefreshWiring', () => {
// Not expressible through the SessionAuthConfig type from a fully-typed
// caller, but envConfig.sessionAuth is only a Partial<SessionAuthConfig> —
// a JS/dynamic caller could still hand cli-builder a refreshOn-only block.
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const refreshOnlySessionAuth = { refreshOn: [401] } as unknown as SessionAuthConfig;
const result = resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined);
expect(result.mergedSessionAuth).toBeUndefined();
// refreshOn still surfaces from the raw (unguarded) merge.
expect(result.refreshOn).toEqual([401]);

try {
const result = resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined);
expect(result.mergedSessionAuth).toBeUndefined();
// refreshOn still surfaces from the raw (unguarded) merge.
expect(result.refreshOn).toEqual([401]);
} finally {
warnSpy.mockRestore();
}
});

test('does not mutate inputs', () => {
Expand All @@ -65,4 +71,41 @@ describe('resolveRefreshWiring', () => {
);
expect(fullSessionAuth).toEqual(sessionAuthCopy);
});

describe('missing session.endpoint diagnostic warning', () => {
test('warns when sessionAuth is set but has no session.endpoint', () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const refreshOnlySessionAuth = { refreshOn: [401] } as unknown as SessionAuthConfig;

try {
resolveRefreshWiring({ sessionAuth: refreshOnlySessionAuth }, undefined);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]![0]).toContain('session.endpoint');
} finally {
warnSpy.mockRestore();
}
});

test('does not warn when there is no sessionAuth at all', () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});

try {
resolveRefreshWiring({ refreshOn: [401] }, undefined);
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});

test('does not warn when sessionAuth has a session.endpoint', () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});

try {
resolveRefreshWiring({ sessionAuth: fullSessionAuth }, undefined);
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
});
});