@ultimat3/auth 31.1.0 → 31.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +12 -4
- package/README.md +38 -9
- package/package.json +4 -4
- package/src/identity-provider-cookie.ts +76 -36
- package/src/identity-provider-oidc.ts +17 -9
- package/src/identity-provider-route-core.ts +13 -13
- package/src/index.ts +8 -5
- package/src/oauth-cookie.ts +7 -83
- package/src/oauth-handshake-cookie.ts +107 -0
- package/src/oauth-paths.ts +3 -1
- package/src/oauth-route.ts +11 -0
- package/src/sign-in-callback-path.ts +61 -0
package/CLAUDE.md
CHANGED
|
@@ -67,8 +67,14 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
|
|
|
67
67
|
`deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`.
|
|
68
68
|
- The code flow carries `nonce` inside the id token. `assertOAuthCallback` checks an echoed one when
|
|
69
69
|
present and never requires it; `verifyIdToken` is the real gate.
|
|
70
|
-
- `sealHandshake`/`openHandshake` (a hand-built flow's
|
|
71
|
-
|
|
70
|
+
- `sealHandshake`/`openHandshake` (a hand-built flow's server-side store): the provider is an
|
|
71
|
+
argument, expiry the server's clock. **The handshake COOKIE is `oauthLogin`'s own**:
|
|
72
|
+
`handshakeCookieName` IS `identityStateCookieName` (`__Host-x_sso_<p>`) and `handshakeCookie`
|
|
73
|
+
seals `encodeOidcState`, so a built-in leg and a hand-built one read each other
|
|
74
|
+
(`oauth-handshake-cookie.test.ts`). The read-only `__Host-x_oauth_<p>` fallback goes in 32.0.0.
|
|
75
|
+
- **`oauthLogin({ callbackPath })` is the one movable path** (`sign-in-callback-path.ts`): mount,
|
|
76
|
+
`redirect_uri` and the checked URL from one pattern, refused if URL normalisation would rewrite
|
|
77
|
+
it; the start path never moves.
|
|
72
78
|
- `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
|
|
73
79
|
conditional statement (`consumed_at is null` on the UPDATE and its subselect,
|
|
74
80
|
`order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
|
|
@@ -84,7 +90,7 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
|
|
|
84
90
|
- Link by address only when the provider **and** the local account both verified it
|
|
85
91
|
(`link: 'verified-email'`, default; `'never'` is the only other value).
|
|
86
92
|
- **One sign-in route implementation** (`identity-provider-route-core.ts`) at
|
|
87
|
-
`/auth/sso/:provider[/callback]
|
|
93
|
+
`/auth/sso/:provider[/callback]` (the callback movable only by `callbackPath`); failure is coded JSON, success a 303 to `successPath`. †
|
|
88
94
|
- Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
|
|
89
95
|
(`oauth-login.ts`) writes `accessToken: null, refreshToken: null`. The columns and `AuthAccount`
|
|
90
96
|
fields stay; an app that stores tokens implements `linkAccount` itself.
|
|
@@ -173,7 +179,9 @@ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../do
|
|
|
173
179
|
| `mfa-secret.ts` | the TOTP secret at rest: seal on write, open on read, the one-shot that seals older rows |
|
|
174
180
|
| `directory.ts` | `describeUser` (allow-list projection), `listOrgUsers`, external-id lookup |
|
|
175
181
|
| `privileges.ts` | `updatePrivileges` — the grant, the rotation it requires, and the sessions a changed password ends |
|
|
176
|
-
| `oauth-cookie.ts` | the handshake's
|
|
182
|
+
| `oauth-cookie.ts` | the handshake's server-side codec: seal, open, secret, TTL |
|
|
183
|
+
| `oauth-handshake-cookie.ts` | the handshake as a cookie — the sign-in routes' own name and seal, plus the one-release legacy read |
|
|
184
|
+
| `sign-in-callback-path.ts` | the callback pattern: default or `callbackPath`, validated; mount, `redirect_uri`, provider segment |
|
|
177
185
|
| `oauth-exchange.ts` | `oauthCredentials` + the one POST to the token endpoint |
|
|
178
186
|
| `id-token.ts` | id token → claims this handshake may believe |
|
|
179
187
|
| `*-fixture.ts` | test-only, off `index.ts`: `id-token-` (the string-input JWT builder), `auth-` (KDF params, password, `AuthError` catcher for `auth*.test.ts`), `oauth-login-` (adapter, clock, profile) |
|
package/README.md
CHANGED
|
@@ -866,11 +866,35 @@ router that can match a `:param` — and `runtime.routes` is the one an app has.
|
|
|
866
866
|
framework's own routes and before the pages; every field it shares the file with is in
|
|
867
867
|
[`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
|
|
868
868
|
|
|
869
|
-
**The
|
|
870
|
-
declaration the mount reads
|
|
871
|
-
the base is `/auth/sso`: `oauthLogin` is a front door over
|
|
872
|
-
and SAML share the paths, the sealed state cookie and the
|
|
873
|
-
|
|
869
|
+
**The start path is fixed; the callback path is not.** `X_OAUTH_STATE_INVALID` quotes
|
|
870
|
+
`oauthStartPath()`, the same declaration the start mount reads — a movable start path is that
|
|
871
|
+
sentence going stale again. Since 31.0.0 the base is `/auth/sso`: `oauthLogin` is a front door over
|
|
872
|
+
`identityProviderRoutes`' legs, so OAuth and SAML share the paths, the sealed state cookie and the
|
|
873
|
+
refusal body. The callback defaults to `/auth/sso/<provider>/callback`, which each IdP must have
|
|
874
|
+
registered (`wiki/Upgrading.md`) — or keep the URI it already has with
|
|
875
|
+
[`oauthLogin({ callbackPath })`](#keeping-a-registered-callback-url).
|
|
876
|
+
|
|
877
|
+
### Keeping a registered callback URL
|
|
878
|
+
|
|
879
|
+
`oauthLogin(auth, { callbackPath })` moves the callback
|
|
880
|
+
leg — its mount (`callback.path`), the `redirect_uri` every provider is sent, and the URL the
|
|
881
|
+
callback checks it against — for an app whose IdP registration cannot change:
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
import { type Auth, oauthLogin } from '@ultimat3/auth';
|
|
885
|
+
|
|
886
|
+
declare const auth: Auth; // your defineAuth(...)
|
|
887
|
+
declare const router: { get(path: string, handle: (request: Request) => Promise<Response>): void };
|
|
888
|
+
|
|
889
|
+
const { start, callback } = oauthLogin(auth, { callbackPath: '/auth/oauth/:provider/callback' });
|
|
890
|
+
router.get(start.path, start.handle); // '/auth/sso/:provider' — the start leg never moves
|
|
891
|
+
router.get(callback.path, callback.handle); // mount callback.path, never OAUTH_CALLBACK_ROUTE_PATH
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
One whole `:provider` segment, absolute, no query, fragment, wildcard or other parameter, no
|
|
895
|
+
segment URL normalisation would rewrite (`.`, `..`, empty, `%`-encoded, `\`), and not the start
|
|
896
|
+
path — anything else is `X_CONFIG_INVALID` at the call. The start leg stays
|
|
897
|
+
`/auth/sso/:provider`: it is a link in your own page, and the `fix:` lines name it.
|
|
874
898
|
|
|
875
899
|
`oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
|
|
876
900
|
With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
|
|
@@ -907,11 +931,16 @@ SameSite=Lax` under a `__Host-` name, and expired against the server's clock rat
|
|
|
907
931
|
client's copy of `Max-Age`. `sealHandshake` / `openHandshake` are the same codec without the
|
|
908
932
|
cookie, for an app that would rather keep it server-side.
|
|
909
933
|
|
|
910
|
-
|
|
911
|
-
`__Host-x_sso_<provider>`,
|
|
912
|
-
|
|
934
|
+
**One cookie, both doors.** `handshakeCookie` writes exactly what `oauthLogin`'s start leg writes —
|
|
935
|
+
the name `__Host-x_sso_<provider>`, the seal, the encoding — so either leg can be the built-in one
|
|
936
|
+
and the other hand-built: `oauthLogin().start` with a callback over `readHandshakeCookie` /
|
|
937
|
+
`completeOAuthLogin`, or a `beginOAuth` + `handshakeCookie` start with `oauthLogin().callback`.
|
|
938
|
+
31.1 and before wrote `__Host-x_oauth_<provider>` from this family, which the start leg never
|
|
939
|
+
read and never wrote. `readHandshakeCookie` still opens that cookie, read-only, for one release —
|
|
940
|
+
a handshake begun on an old pod mid-deploy finishes on a new one — and stops in 32.0.0.
|
|
941
|
+
`clearHandshakeCookie` clears only the current name; a legacy one expires on its own `Max-Age`.
|
|
913
942
|
|
|
914
|
-
**One cookie per provider:** `handshakeCookieName(provider)` → `__Host-
|
|
943
|
+
**One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_sso_github`. A browser
|
|
915
944
|
is one cookie jar and a user is allowed two tabs, so a single shared name means the `google`
|
|
916
945
|
redirect overwrites a `github` handshake still in flight — and the github callback then opens
|
|
917
946
|
google's and fails `X_OAUTH_STATE_INVALID` for a reason no restart clears. `handshakeCookie` takes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "31.
|
|
3
|
+
"version": "31.2.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"test": "bun test"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@ultimat3/core": "31.
|
|
52
|
-
"@ultimat3/db": "31.
|
|
53
|
-
"@ultimat3/schema": "31.
|
|
51
|
+
"@ultimat3/core": "31.2.0",
|
|
52
|
+
"@ultimat3/db": "31.2.0",
|
|
53
|
+
"@ultimat3/schema": "31.2.0"
|
|
54
54
|
}
|
|
55
55
|
}
|
|
@@ -7,8 +7,14 @@ import { type Clock, hmac, readCookie, serializeSetCookie, timingSafeEqual } fro
|
|
|
7
7
|
import type { IdentityProvider } from './identity-provider';
|
|
8
8
|
import { DEFAULT_HANDSHAKE_TTL_MS, handshakeSecret } from './oauth-cookie';
|
|
9
9
|
import { oauthStateInvalid } from './oauth-errors';
|
|
10
|
+
import { assertFiniteAuthCount } from './policy-numbers';
|
|
10
11
|
import { base64Url, base64UrlBytes } from './tokens';
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The ONE name every sign-in state cookie is written and read under — the built-in legs and the
|
|
15
|
+
* exported `handshakeCookie` family alike (`handshakeCookieName` is this function). Two names
|
|
16
|
+
* once let a built-in start leg and a hand-built callback miss each other entirely.
|
|
17
|
+
*/
|
|
12
18
|
export const IDENTITY_STATE_COOKIE_PREFIX = '__Host-x_sso';
|
|
13
19
|
|
|
14
20
|
export const identityStateCookieName = (provider: string): string =>
|
|
@@ -18,56 +24,41 @@ export interface IdentityStateOptions {
|
|
|
18
24
|
readonly clock: Clock;
|
|
19
25
|
/** Defaults to `SESSION_SECRET`. */
|
|
20
26
|
readonly secret?: string | undefined;
|
|
27
|
+
/** Defaults to `DEFAULT_HANDSHAKE_TTL_MS`; the caller screens it. */
|
|
28
|
+
readonly ttlMs?: number | undefined;
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
type CallbackMethod = IdentityProvider['callbackMethod'];
|
|
32
|
+
|
|
23
33
|
const sign = (body: string, secret: string): string => hmac('sha256', secret, body, 'hex');
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
const sameSite = (method: CallbackMethod) => (method === 'POST' ? 'None' : 'Lax');
|
|
36
|
+
|
|
37
|
+
/** `[id, state, issuedAt]`, signed: the value both cookie writers put under the one name. */
|
|
38
|
+
export function sealIdentityState(
|
|
39
|
+
id: string,
|
|
27
40
|
state: string,
|
|
28
41
|
options: IdentityStateOptions,
|
|
29
42
|
): string {
|
|
30
43
|
const body = base64Url(
|
|
31
|
-
new TextEncoder().encode(JSON.stringify([
|
|
44
|
+
new TextEncoder().encode(JSON.stringify([id, state, options.clock.now().getTime()])),
|
|
32
45
|
);
|
|
33
|
-
|
|
34
|
-
return serializeSetCookie(identityStateCookieName(provider.id), sealed, {
|
|
35
|
-
maxAge: Math.floor(DEFAULT_HANDSHAKE_TTL_MS / 1000),
|
|
36
|
-
sameSite: provider.callbackMethod === 'POST' ? 'None' : 'Lax',
|
|
37
|
-
});
|
|
46
|
+
return `${body}.${sign(body, options.secret ?? handshakeSecret())}`;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*/
|
|
44
|
-
export const clearIdentityStateCookie = (
|
|
49
|
+
/** The state `sealIdentityState` sealed for `id`, or `X_OAUTH_STATE_INVALID` for every failure. */
|
|
50
|
+
export function openIdentityState(
|
|
51
|
+
sealed: string,
|
|
45
52
|
id: string,
|
|
46
|
-
callbackMethod: IdentityProvider['callbackMethod'],
|
|
47
|
-
): string =>
|
|
48
|
-
serializeSetCookie(identityStateCookieName(id), '', {
|
|
49
|
-
maxAge: 0,
|
|
50
|
-
sameSite: callbackMethod === 'POST' ? 'None' : 'Lax',
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
/** The state `begin` returned, or `X_OAUTH_STATE_INVALID` — one refusal for every way it fails. */
|
|
54
|
-
export function readIdentityState(
|
|
55
|
-
request: Request,
|
|
56
|
-
provider: IdentityProvider,
|
|
57
53
|
options: IdentityStateOptions,
|
|
58
54
|
): string {
|
|
59
|
-
const sealed = readCookie(request.headers.get('cookie'), identityStateCookieName(provider.id));
|
|
60
|
-
if (sealed === null) throw oauthStateInvalid(provider.id, 'no sign-in state cookie arrived');
|
|
61
55
|
const dot = sealed.lastIndexOf('.');
|
|
62
56
|
const body = sealed.slice(0, Math.max(dot, 0));
|
|
63
57
|
if (
|
|
64
58
|
dot <= 0 ||
|
|
65
59
|
!timingSafeEqual(sign(body, options.secret ?? handshakeSecret()), sealed.slice(dot + 1))
|
|
66
60
|
) {
|
|
67
|
-
throw oauthStateInvalid(
|
|
68
|
-
provider.id,
|
|
69
|
-
'the sign-in state was tampered with, or the secret rotated',
|
|
70
|
-
);
|
|
61
|
+
throw oauthStateInvalid(id, 'the sign-in state was tampered with, or the secret rotated');
|
|
71
62
|
}
|
|
72
63
|
let parsed: unknown;
|
|
73
64
|
try {
|
|
@@ -76,14 +67,63 @@ export function readIdentityState(
|
|
|
76
67
|
parsed = null;
|
|
77
68
|
}
|
|
78
69
|
if (!Array.isArray(parsed) || parsed.length !== 3) {
|
|
79
|
-
throw oauthStateInvalid(
|
|
70
|
+
throw oauthStateInvalid(id, 'the sign-in state is not readable');
|
|
80
71
|
}
|
|
81
|
-
const [
|
|
82
|
-
if (
|
|
83
|
-
throw oauthStateInvalid(
|
|
72
|
+
const [sealedId, state, issuedAt] = parsed as readonly unknown[];
|
|
73
|
+
if (sealedId !== id || typeof state !== 'string' || typeof issuedAt !== 'number') {
|
|
74
|
+
throw oauthStateInvalid(id, 'the sign-in state belongs to another provider');
|
|
84
75
|
}
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
// Screened at the reader: `now - issuedAt > NaN` is false forever, so a NaN deadline never expires.
|
|
77
|
+
const ttlMs = assertFiniteAuthCount(
|
|
78
|
+
'oauth.handshake.ttlMs',
|
|
79
|
+
options.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
|
|
80
|
+
'the server-side replay deadline is a comparison that is false for every sign-in state, so a lifted one stays redeemable',
|
|
81
|
+
1,
|
|
82
|
+
);
|
|
83
|
+
if (options.clock.now().getTime() - issuedAt > ttlMs) {
|
|
84
|
+
throw oauthStateInvalid(id, 'the sign-in state expired before the IdP answered');
|
|
87
85
|
}
|
|
88
86
|
return state;
|
|
89
87
|
}
|
|
88
|
+
|
|
89
|
+
/** A sealed value as a `Set-Cookie`; `name` defaults to the one name. */
|
|
90
|
+
export const serializeIdentityState = (
|
|
91
|
+
id: string,
|
|
92
|
+
value: string,
|
|
93
|
+
callbackMethod: CallbackMethod,
|
|
94
|
+
maxAge: number,
|
|
95
|
+
name: string = identityStateCookieName(id),
|
|
96
|
+
): string => serializeSetCookie(name, value, { maxAge, sameSite: sameSite(callbackMethod) });
|
|
97
|
+
|
|
98
|
+
export const identityStateCookie = (
|
|
99
|
+
provider: IdentityProvider,
|
|
100
|
+
state: string,
|
|
101
|
+
options: IdentityStateOptions,
|
|
102
|
+
): string =>
|
|
103
|
+
serializeIdentityState(
|
|
104
|
+
provider.id,
|
|
105
|
+
sealIdentityState(provider.id, state, options),
|
|
106
|
+
provider.callbackMethod,
|
|
107
|
+
Math.floor(DEFAULT_HANDSHAKE_TTL_MS / 1000),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Sent with every callback answer: the state it carried is spent whatever the outcome. Takes the id
|
|
112
|
+
* and method rather than a provider, so a refusal before the provider resolved can still clear it.
|
|
113
|
+
*/
|
|
114
|
+
export const clearIdentityStateCookie = (
|
|
115
|
+
id: string,
|
|
116
|
+
callbackMethod: CallbackMethod,
|
|
117
|
+
name?: string,
|
|
118
|
+
): string => serializeIdentityState(id, '', callbackMethod, 0, name);
|
|
119
|
+
|
|
120
|
+
/** The state `begin` returned, or `X_OAUTH_STATE_INVALID` — one refusal for every way it fails. */
|
|
121
|
+
export function readIdentityState(
|
|
122
|
+
request: Request,
|
|
123
|
+
provider: IdentityProvider,
|
|
124
|
+
options: IdentityStateOptions,
|
|
125
|
+
): string {
|
|
126
|
+
const sealed = readCookie(request.headers.get('cookie'), identityStateCookieName(provider.id));
|
|
127
|
+
if (sealed === null) throw oauthStateInvalid(provider.id, 'no sign-in state cookie arrived');
|
|
128
|
+
return openIdentityState(sealed, provider.id, options);
|
|
129
|
+
}
|
|
@@ -43,12 +43,23 @@ export interface OidcIdentityProviderConfig {
|
|
|
43
43
|
const FIELDS = ['state', 'nonce', 'verifier', 'redirectUri', 'authorizeUrl'] as const;
|
|
44
44
|
|
|
45
45
|
/** The handshake, and when it was issued — the instant its single-use claim is keyed on. */
|
|
46
|
-
interface StoredHandshake {
|
|
46
|
+
export interface StoredHandshake {
|
|
47
47
|
readonly handshake: OAuthHandshake;
|
|
48
48
|
readonly issuedAt: number;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The handshake as the sign-in state string. The ONE encoding: `begin` writes it, and so does
|
|
53
|
+
* `handshakeCookie`, so a hand-built leg and a built-in one read each other's cookie.
|
|
54
|
+
*/
|
|
55
|
+
export const encodeOidcState = (handshake: OAuthHandshake, issuedAt: number): string =>
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
...Object.fromEntries(FIELDS.map((field) => [field, handshake[field]])),
|
|
58
|
+
issuedAt,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** `encodeOidcState`'s inverse, bound to `provider`; `X_OAUTH_STATE_INVALID` for anything else. */
|
|
62
|
+
export function decodeOidcState(provider: OAuthProviderId, state: string): StoredHandshake {
|
|
52
63
|
let parsed: unknown;
|
|
53
64
|
try {
|
|
54
65
|
parsed = JSON.parse(state);
|
|
@@ -91,21 +102,18 @@ export function oidcIdentityProvider(config: OidcIdentityProviderConfig): Identi
|
|
|
91
102
|
redirectUri: callbackUrl,
|
|
92
103
|
scopes: config.scopes,
|
|
93
104
|
});
|
|
94
|
-
const state =
|
|
95
|
-
|
|
96
|
-
issuedAt: clock.now().getTime(),
|
|
97
|
-
};
|
|
98
|
-
return { location: handshake.authorizeUrl, state: JSON.stringify(state) };
|
|
105
|
+
const state = encodeOidcState(handshake, clock.now().getTime());
|
|
106
|
+
return { location: handshake.authorizeUrl, state };
|
|
99
107
|
},
|
|
100
108
|
answers(state, params) {
|
|
101
109
|
try {
|
|
102
|
-
return timingSafeEqual(
|
|
110
|
+
return timingSafeEqual(decodeOidcState(id, state).handshake.state, params['state'] ?? '');
|
|
103
111
|
} catch {
|
|
104
112
|
return false;
|
|
105
113
|
}
|
|
106
114
|
},
|
|
107
115
|
async complete({ state, params, callbackUrl, clock, replay }): Promise<VerifiedIdentity> {
|
|
108
|
-
const { handshake, issuedAt } =
|
|
116
|
+
const { handshake, issuedAt } = decodeOidcState(id, state);
|
|
109
117
|
if (handshake.redirectUri !== callbackUrl) {
|
|
110
118
|
throw oauthStateInvalid(id, 'the handshake was started for another callback URL');
|
|
111
119
|
}
|
|
@@ -24,12 +24,9 @@ import {
|
|
|
24
24
|
import { oauthRouteStatus } from './identity-provider-status';
|
|
25
25
|
import { oauthStateInvalid } from './oauth-errors';
|
|
26
26
|
import type { OAuthGrants } from './oauth-login';
|
|
27
|
-
import {
|
|
28
|
-
OAUTH_CALLBACK_ROUTE_PATH,
|
|
29
|
-
OAUTH_START_ROUTE_PATH,
|
|
30
|
-
oauthCallbackPath,
|
|
31
|
-
} from './oauth-paths';
|
|
27
|
+
import { OAUTH_START_ROUTE_PATH } from './oauth-paths';
|
|
32
28
|
import { samlResponseInvalid } from './saml-errors';
|
|
29
|
+
import { type CallbackPath, callbackPath } from './sign-in-callback-path';
|
|
33
30
|
|
|
34
31
|
export interface SignInRouteDescriptor {
|
|
35
32
|
readonly method: 'GET' | 'POST';
|
|
@@ -85,6 +82,8 @@ export interface SignInRoutePolicy {
|
|
|
85
82
|
/** A throw with no code, logged and turned into the refusal served. Its text never is. */
|
|
86
83
|
uncoded(error: unknown): UltimateError;
|
|
87
84
|
grants(identity: VerifiedIdentity): Promise<OAuthGrants | undefined> | OAuthGrants | undefined;
|
|
85
|
+
/** The callback leg's pattern; `oauthLogin({ callbackPath })` only. Default: the framework's. */
|
|
86
|
+
readonly callbackPath?: CallbackPath | undefined;
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
export interface SignInRoutes {
|
|
@@ -120,9 +119,9 @@ function problem(policy: SignInRoutePolicy, error: unknown, cookies: readonly st
|
|
|
120
119
|
return new Response(JSON.stringify(body), { status, headers });
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
function
|
|
122
|
+
function startSegment(request: Request): string {
|
|
124
123
|
const parts = new URL(request.url).pathname.split('/').filter((part) => part !== '');
|
|
125
|
-
return parts[
|
|
124
|
+
return parts[parts.length - 1] ?? '';
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
function origin(options: SignInRouteOptions): string {
|
|
@@ -169,6 +168,7 @@ export function signInRoutes(
|
|
|
169
168
|
options: SignInRouteOptions,
|
|
170
169
|
policy: SignInRoutePolicy,
|
|
171
170
|
): SignInRoutes {
|
|
171
|
+
const back = policy.callbackPath ?? callbackPath();
|
|
172
172
|
const seal = {
|
|
173
173
|
clock: auth.clock,
|
|
174
174
|
...(options.secret === undefined ? {} : { secret: options.secret }),
|
|
@@ -179,8 +179,8 @@ export function signInRoutes(
|
|
|
179
179
|
// Before the provider: asked after, a missing origin answered 500 for an enabled provider
|
|
180
180
|
// and 404 for one that is not — an oracle for the app's configuration.
|
|
181
181
|
const base = origin(options);
|
|
182
|
-
const provider = policy.resolve(
|
|
183
|
-
const callbackUrl = `${base}${
|
|
182
|
+
const provider = policy.resolve(startSegment(request), 'start');
|
|
183
|
+
const callbackUrl = `${base}${back.pathFor(provider.id)}`;
|
|
184
184
|
const redirect = await provider.begin({ callbackUrl, clock: auth.clock });
|
|
185
185
|
const headers = new Headers({ location: redirect.location });
|
|
186
186
|
headers.append('set-cookie', identityStateCookie(provider, redirect.state, seal));
|
|
@@ -191,7 +191,7 @@ export function signInRoutes(
|
|
|
191
191
|
};
|
|
192
192
|
|
|
193
193
|
const callback = async (request: Request): Promise<Response> => {
|
|
194
|
-
const segment =
|
|
194
|
+
const segment = back.providerOf(new URL(request.url).pathname);
|
|
195
195
|
// Set only once the answer is bound to this browser's state; then cleared on every outcome,
|
|
196
196
|
// since that answer is spent either way. An unbound answer — a junk cross-site POST riding the
|
|
197
197
|
// SameSite=None cookie — clears nothing, so it cannot end a sign-in in flight.
|
|
@@ -213,7 +213,7 @@ export function signInRoutes(
|
|
|
213
213
|
const identity = await provider.complete({
|
|
214
214
|
state,
|
|
215
215
|
params,
|
|
216
|
-
callbackUrl: `${base}${
|
|
216
|
+
callbackUrl: `${base}${back.pathFor(provider.id)}`,
|
|
217
217
|
clock: auth.clock,
|
|
218
218
|
replay: auth.totpReplay,
|
|
219
219
|
});
|
|
@@ -247,7 +247,7 @@ export function signInRoutes(
|
|
|
247
247
|
};
|
|
248
248
|
return Object.freeze({
|
|
249
249
|
start: route('GET', OAUTH_START_ROUTE_PATH, policy.names.start, start),
|
|
250
|
-
callback: route('GET',
|
|
251
|
-
acs: route('POST',
|
|
250
|
+
callback: route('GET', back.pattern, policy.names.callback, callback),
|
|
251
|
+
acs: route('POST', back.pattern, policy.names.acs, callback),
|
|
252
252
|
});
|
|
253
253
|
}
|
package/src/index.ts
CHANGED
|
@@ -243,15 +243,11 @@ export {
|
|
|
243
243
|
GITHUB_PROVIDER,
|
|
244
244
|
GOOGLE_PROVIDER,
|
|
245
245
|
} from './oauth-builtins';
|
|
246
|
-
export type {
|
|
246
|
+
export type { HandshakeSealOptions } from './oauth-cookie';
|
|
247
247
|
export {
|
|
248
|
-
clearHandshakeCookie,
|
|
249
248
|
DEFAULT_HANDSHAKE_TTL_MS,
|
|
250
|
-
handshakeCookie,
|
|
251
|
-
handshakeCookieName,
|
|
252
249
|
handshakeSecret,
|
|
253
250
|
openHandshake,
|
|
254
|
-
readHandshakeCookie,
|
|
255
251
|
sealHandshake,
|
|
256
252
|
} from './oauth-cookie';
|
|
257
253
|
export type { DiscoverOAuthProviderInput } from './oauth-discovery';
|
|
@@ -273,6 +269,13 @@ export type {
|
|
|
273
269
|
} from './oauth-exchange';
|
|
274
270
|
export { exchangeOAuthCode, oauthCredentials } from './oauth-exchange';
|
|
275
271
|
export type { OAuthGrantContext } from './oauth-grant-context';
|
|
272
|
+
export type { HandshakeCookieOptions } from './oauth-handshake-cookie';
|
|
273
|
+
export {
|
|
274
|
+
clearHandshakeCookie,
|
|
275
|
+
handshakeCookie,
|
|
276
|
+
handshakeCookieName,
|
|
277
|
+
readHandshakeCookie,
|
|
278
|
+
} from './oauth-handshake-cookie';
|
|
276
279
|
export type {
|
|
277
280
|
CompleteOAuthLoginInput,
|
|
278
281
|
OAuthGrants,
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -1,40 +1,17 @@
|
|
|
1
|
-
// Single responsibility:
|
|
2
|
-
// two legs of an authorization-code login are separate HTTP requests,
|
|
3
|
-
// nonce and PKCE verifier have to survive the round trip — and a framework
|
|
4
|
-
// the app gets one hand-rolled store per app, which is exactly where PKCE
|
|
5
|
-
//
|
|
6
|
-
// format at one trust level.
|
|
1
|
+
// Single responsibility: the handshake's server-side codec (`sealHandshake` / `openHandshake`), its
|
|
2
|
+
// secret and its deadline. The two legs of an authorization-code login are separate HTTP requests,
|
|
3
|
+
// so `beginOAuth`'s state, nonce and PKCE verifier have to survive the round trip — and a framework
|
|
4
|
+
// that leaves that to the app gets one hand-rolled store per app, which is exactly where PKCE
|
|
5
|
+
// quietly stops proving anything. The cookie is `oauth-handshake-cookie.ts`.
|
|
7
6
|
|
|
8
7
|
import type { Clock } from '@ultimat3/core';
|
|
9
|
-
import {
|
|
10
|
-
EnvMissingError,
|
|
11
|
-
hmac,
|
|
12
|
-
readCookie,
|
|
13
|
-
serializeSetCookie,
|
|
14
|
-
systemClock,
|
|
15
|
-
timingSafeEqual,
|
|
16
|
-
} from '@ultimat3/core';
|
|
8
|
+
import { EnvMissingError, hmac, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
17
9
|
import type { OAuthHandshake, OAuthProviderId } from './oauth';
|
|
18
10
|
import { oauthStateInvalid } from './oauth-errors';
|
|
19
11
|
import { hasOAuthProvider } from './oauth-registry';
|
|
20
12
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
21
|
-
import type { RequestLike } from './session';
|
|
22
13
|
import { base64Url } from './tokens';
|
|
23
14
|
|
|
24
|
-
/** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
|
|
25
|
-
export const OAUTH_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs.
|
|
29
|
-
* Under a single shared name, starting a `google` login while a `github` one is mid-flight
|
|
30
|
-
* overwrites the github handshake — and the github callback then opens google's, fails
|
|
31
|
-
* `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again.
|
|
32
|
-
* Scoping the name makes the two handshakes independent instead of the last writer's.
|
|
33
|
-
*/
|
|
34
|
-
export function handshakeCookieName(provider: OAuthProviderId): string {
|
|
35
|
-
return `${OAUTH_HANDSHAKE_COOKIE_PREFIX}_${provider}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
15
|
/** Long enough to read a consent screen, short enough that a lifted cookie is already stale. */
|
|
39
16
|
export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
|
|
40
17
|
|
|
@@ -49,7 +26,7 @@ export const DEFAULT_HANDSHAKE_TTL_MS = 10 * 60 * 1000;
|
|
|
49
26
|
* Screened at every reader rather than once at seal time: the two legs are two HTTP requests and
|
|
50
27
|
* two calls, so a check on the way out is one the way back never runs.
|
|
51
28
|
*/
|
|
52
|
-
function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
|
|
29
|
+
export function handshakeTtlMs(options: HandshakeSealOptions | undefined): number {
|
|
53
30
|
return assertFiniteAuthCount(
|
|
54
31
|
'oauth.handshake.ttlMs',
|
|
55
32
|
options?.ttlMs ?? DEFAULT_HANDSHAKE_TTL_MS,
|
|
@@ -91,15 +68,6 @@ export interface HandshakeSealOptions {
|
|
|
91
68
|
readonly ttlMs?: number | undefined;
|
|
92
69
|
}
|
|
93
70
|
|
|
94
|
-
export interface HandshakeCookieOptions extends HandshakeSealOptions {
|
|
95
|
-
/**
|
|
96
|
-
* Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider
|
|
97
|
-
* scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on
|
|
98
|
-
* the callback reads a cookie that is not there.
|
|
99
|
-
*/
|
|
100
|
-
readonly name?: string | undefined;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
71
|
/**
|
|
104
72
|
* Signed, not encrypted, for the reason the cursor codec is: every field here is already the
|
|
105
73
|
* browser's own — `state` travelled in the URL it was just sent to, and the verifier and nonce
|
|
@@ -182,50 +150,6 @@ export function openHandshake(
|
|
|
182
150
|
return { provider, state, nonce, verifier, redirectUri, authorizeUrl };
|
|
183
151
|
}
|
|
184
152
|
|
|
185
|
-
/**
|
|
186
|
-
* Set on the redirect. `SameSite=Lax` is the one attribute that differs in reasoning from the
|
|
187
|
-
* session cookie's: the callback is a top-level cross-site GET from the provider, which `Lax`
|
|
188
|
-
* still attaches the cookie to and `Strict` would strip — leaving every login to fail its state
|
|
189
|
-
* check. A provider answering with `response_mode=form_post` POSTs instead, and this cookie
|
|
190
|
-
* would not reach it; neither built-in provider is configured that way.
|
|
191
|
-
*/
|
|
192
|
-
export function handshakeCookie(
|
|
193
|
-
handshake: OAuthHandshake,
|
|
194
|
-
options?: HandshakeCookieOptions,
|
|
195
|
-
): string {
|
|
196
|
-
const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
|
|
197
|
-
// The handshake already names its provider, so the two legs cannot disagree about the name.
|
|
198
|
-
const name = options?.name ?? handshakeCookieName(handshake.provider);
|
|
199
|
-
return serializeSetCookie(name, sealHandshake(handshake, options), { maxAge });
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Send this with the callback's response, always — an authorization code is single-use, so the
|
|
204
|
-
* handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin.
|
|
205
|
-
*
|
|
206
|
-
* `provider` is required for the reason `openHandshake`'s is: clearing the unscoped name would
|
|
207
|
-
* clear nothing, and clearing every provider's would cancel a login running in another tab.
|
|
208
|
-
*/
|
|
209
|
-
export function clearHandshakeCookie(provider: OAuthProviderId, name?: string): string {
|
|
210
|
-
return serializeSetCookie(name ?? handshakeCookieName(provider), '', { maxAge: 0 });
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Reads the cookie set on the redirect and returns the handshake `completeOAuthLogin` takes. */
|
|
214
|
-
export function readHandshakeCookie(
|
|
215
|
-
request: RequestLike,
|
|
216
|
-
provider: OAuthProviderId,
|
|
217
|
-
options?: HandshakeCookieOptions,
|
|
218
|
-
): OAuthHandshake {
|
|
219
|
-
const sealed = readCookie(
|
|
220
|
-
request.headers.get('cookie'),
|
|
221
|
-
options?.name ?? handshakeCookieName(provider),
|
|
222
|
-
);
|
|
223
|
-
if (sealed === null) {
|
|
224
|
-
throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
|
|
225
|
-
}
|
|
226
|
-
return openHandshake(sealed, provider, options);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
153
|
/** Keyed SHA-256. Untruncated, unlike a cursor's: a cookie has room and this authorises a login. */
|
|
230
154
|
function sign(body: string, secret: string): string {
|
|
231
155
|
return hmac('sha256', secret, body, 'hex');
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Single responsibility: the OAuth handshake as a cookie, for a hand-built leg over `beginOAuth` /
|
|
2
|
+
// `completeOAuthLogin`. Same name, same seal, same encoding as `oauthLogin`'s own state cookie
|
|
3
|
+
// (`identity-provider-cookie.ts`, `encodeOidcState`), so either leg can be the built-in one: a
|
|
4
|
+
// second name once left a built-in start leg and a hand-built callback unable to find each other.
|
|
5
|
+
|
|
6
|
+
import { readCookie, systemClock } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
clearIdentityStateCookie,
|
|
9
|
+
identityStateCookieName,
|
|
10
|
+
openIdentityState,
|
|
11
|
+
sealIdentityState,
|
|
12
|
+
serializeIdentityState,
|
|
13
|
+
} from './identity-provider-cookie';
|
|
14
|
+
import { decodeOidcState, encodeOidcState } from './identity-provider-oidc';
|
|
15
|
+
import type { OAuthHandshake, OAuthProviderId } from './oauth';
|
|
16
|
+
import { type HandshakeSealOptions, handshakeTtlMs, openHandshake } from './oauth-cookie';
|
|
17
|
+
import { oauthStateInvalid } from './oauth-errors';
|
|
18
|
+
import type { RequestLike } from './session';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One cookie per provider, because a browser is one cookie jar and a user is allowed two tabs.
|
|
22
|
+
* Under a single shared name, starting a `google` login while a `github` one is mid-flight
|
|
23
|
+
* overwrites the github handshake — and the github callback then opens google's, fails
|
|
24
|
+
* `X_OAUTH_STATE_INVALID` and tells the user to restart the flow that just collided again.
|
|
25
|
+
* The name is the sign-in routes' own: `__Host-x_sso_<provider>`.
|
|
26
|
+
*/
|
|
27
|
+
export const handshakeCookieName = (provider: OAuthProviderId): string =>
|
|
28
|
+
identityStateCookieName(provider);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The name, and the `sealHandshake` format, this family wrote up to 31.1. Read — never written —
|
|
32
|
+
* so a handshake begun on an old pod finishes on a new one during the deploy. Its life is the
|
|
33
|
+
* handshake TTL (10 min by default), so one release of reading it is the whole transition.
|
|
34
|
+
* Delete with the fallback below in 32.0.0.
|
|
35
|
+
*/
|
|
36
|
+
const LEGACY_HANDSHAKE_COOKIE_PREFIX = '__Host-x_oauth';
|
|
37
|
+
|
|
38
|
+
export interface HandshakeCookieOptions extends HandshakeSealOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Defaults to `handshakeCookieName(provider)`. Overriding it opts out of the per-provider
|
|
41
|
+
* scoping, so both legs have to pass the same one — a name set on the redirect and defaulted on
|
|
42
|
+
* the callback reads a cookie that is not there.
|
|
43
|
+
*/
|
|
44
|
+
readonly name?: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set on the redirect. `SameSite=Lax`: the callback is a top-level cross-site GET from the
|
|
49
|
+
* provider, which `Lax` still attaches the cookie to and `Strict` would strip. A provider answering
|
|
50
|
+
* with `response_mode=form_post` POSTs instead, and this cookie would not reach it.
|
|
51
|
+
*/
|
|
52
|
+
export function handshakeCookie(
|
|
53
|
+
handshake: OAuthHandshake,
|
|
54
|
+
options?: HandshakeCookieOptions,
|
|
55
|
+
): string {
|
|
56
|
+
const maxAge = Math.floor(handshakeTtlMs(options) / 1000);
|
|
57
|
+
const clock = options?.clock ?? systemClock;
|
|
58
|
+
const state = encodeOidcState(handshake, clock.now().getTime());
|
|
59
|
+
const sealed = sealIdentityState(handshake.provider, state, { ...options, clock });
|
|
60
|
+
// The handshake already names its provider, so the two legs cannot disagree about the name.
|
|
61
|
+
return serializeIdentityState(handshake.provider, sealed, 'GET', maxAge, options?.name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send this with the callback's response, always — an authorization code is single-use, so the
|
|
66
|
+
* handshake that authorised it must not outlive it. A mismatched attribute set leaves a live twin.
|
|
67
|
+
*
|
|
68
|
+
* `provider` is required: clearing every provider's would cancel a login running in another tab.
|
|
69
|
+
* A legacy `__Host-x_oauth_<p>` cookie is not cleared — one `Set-Cookie` names one cookie; it
|
|
70
|
+
* expires on its own `Max-Age`, and the code it guarded is spent.
|
|
71
|
+
*/
|
|
72
|
+
export const clearHandshakeCookie = (provider: OAuthProviderId, name?: string): string =>
|
|
73
|
+
clearIdentityStateCookie(provider, 'GET', name);
|
|
74
|
+
|
|
75
|
+
/** Opens the current format, else the legacy one; the first refusal is the one reported. */
|
|
76
|
+
function open(sealed: string, provider: OAuthProviderId, options?: HandshakeSealOptions) {
|
|
77
|
+
const clock = options?.clock ?? systemClock;
|
|
78
|
+
try {
|
|
79
|
+
const ttlMs = handshakeTtlMs(options);
|
|
80
|
+
const state = openIdentityState(sealed, provider, { ...options, clock, ttlMs });
|
|
81
|
+
return decodeOidcState(provider, state).handshake;
|
|
82
|
+
} catch (refusal) {
|
|
83
|
+
try {
|
|
84
|
+
return openHandshake(sealed, provider, options);
|
|
85
|
+
} catch {
|
|
86
|
+
throw refusal;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reads the cookie set on the redirect — by either leg — and returns the handshake. */
|
|
92
|
+
export function readHandshakeCookie(
|
|
93
|
+
request: RequestLike,
|
|
94
|
+
provider: OAuthProviderId,
|
|
95
|
+
options?: HandshakeCookieOptions,
|
|
96
|
+
): OAuthHandshake {
|
|
97
|
+
const header = request.headers.get('cookie');
|
|
98
|
+
const sealed =
|
|
99
|
+
readCookie(header, options?.name ?? handshakeCookieName(provider)) ??
|
|
100
|
+
(options?.name === undefined
|
|
101
|
+
? readCookie(header, `${LEGACY_HANDSHAKE_COOKIE_PREFIX}_${provider}`)
|
|
102
|
+
: null);
|
|
103
|
+
if (sealed === null) {
|
|
104
|
+
throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
|
|
105
|
+
}
|
|
106
|
+
return open(sealed, provider, options);
|
|
107
|
+
}
|
package/src/oauth-paths.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// line that names a route nothing serves impossible rather than merely discouraged.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* The base and the start path are not configurable, on purpose: a movable start path is a `fix:`
|
|
9
|
+
* line that can go stale again. The callback pattern is the one exception — `oauthLogin({
|
|
10
|
+
* callbackPath })`, for a `redirect_uri` an IdP already has registered — and no `fix:` quotes it.
|
|
9
11
|
* `/auth/sso` since 31.0.0: every `IdentityProvider` — OIDC and SAML — signs in through it.
|
|
10
12
|
*/
|
|
11
13
|
export const OAUTH_BASE_PATH = '/auth/sso';
|
package/src/oauth-route.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
|
|
|
30
30
|
import { oauthCredentials } from './oauth-exchange';
|
|
31
31
|
import type { ResolveOAuthGrants } from './oauth-login';
|
|
32
32
|
import { hasOAuthProvider } from './oauth-registry';
|
|
33
|
+
import { callbackPath } from './sign-in-callback-path';
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
|
|
@@ -97,6 +98,15 @@ export interface OAuthLoginOptions {
|
|
|
97
98
|
* branch: `providerJwks(providerFor('github'))` throws, because GitHub publishes no `jwks_uri`.
|
|
98
99
|
*/
|
|
99
100
|
readonly idTokenKeys?: ((provider: OAuthProviderId) => IdTokenKeys | undefined) | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* The callback leg's mount pattern, one whole `:provider` segment — and so the `redirect_uri`
|
|
103
|
+
* every provider is sent and must have registered. Defaults to `OAUTH_CALLBACK_ROUTE_PATH`
|
|
104
|
+
* (`/auth/sso/:provider/callback`). Set it to keep a URI already registered at an IdP you cannot
|
|
105
|
+
* edit, e.g. 30.x's `'/auth/oauth/:provider/callback'`, and mount `callback.path`, never the
|
|
106
|
+
* constant. The start leg does not move: it is a link in your own page, and `fix:` lines name it.
|
|
107
|
+
* A pattern a router cannot mount is `X_CONFIG_INVALID` here, at the call.
|
|
108
|
+
*/
|
|
109
|
+
readonly callbackPath?: string | undefined;
|
|
100
110
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -171,6 +181,7 @@ const asGet = (descriptor: SignInRouteDescriptor): AuthRouteDescriptor =>
|
|
|
171
181
|
*/
|
|
172
182
|
export function oauthLogin(auth: Auth, options: OAuthLoginOptions = {}): OAuthLoginRoutes {
|
|
173
183
|
const routes = signInRoutes(auth, options, {
|
|
184
|
+
callbackPath: callbackPath(options.callbackPath),
|
|
174
185
|
resolve(segment, leg) {
|
|
175
186
|
if (!hasOAuthProvider(segment) || !auth.providers.includes(segment)) throw unknown(segment);
|
|
176
187
|
// Asked on the callback only: the start leg verifies nothing.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Single responsibility: the callback leg's path pattern — the default, or an app's own
|
|
2
|
+
// (`oauthLogin({ callbackPath })`) — refused at construction when a router could not mount it or the
|
|
3
|
+
// provider could not be read back out of it. One value yields the mount, the `redirect_uri` sent
|
|
4
|
+
// to the IdP and the provider segment of an arriving request, so the three cannot disagree.
|
|
5
|
+
|
|
6
|
+
import { ConfigInvalidError, renderCauseValue } from '@ultimat3/core';
|
|
7
|
+
import { OAUTH_CALLBACK_ROUTE_PATH, OAUTH_START_ROUTE_PATH } from './oauth-paths';
|
|
8
|
+
|
|
9
|
+
const PARAM = ':provider';
|
|
10
|
+
|
|
11
|
+
export interface CallbackPath {
|
|
12
|
+
/** The mount pattern, `:provider` included. */
|
|
13
|
+
readonly pattern: string;
|
|
14
|
+
/** The concrete path for one provider: what `redirect_uri` carries. */
|
|
15
|
+
pathFor(provider: string): string;
|
|
16
|
+
/** The provider segment of a request this pattern matched, counted from the END of the path. */
|
|
17
|
+
providerOf(pathname: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const split = (path: string): string[] => path.split('/').filter((part) => part !== '');
|
|
21
|
+
|
|
22
|
+
function refuse(pattern: string, why: string): ConfigInvalidError {
|
|
23
|
+
return new ConfigInvalidError({
|
|
24
|
+
cause: `oauthLogin({ callbackPath: ${renderCauseValue(pattern)} }) ${why}`,
|
|
25
|
+
fix: "oauthLogin(auth, { callbackPath: '/auth/oauth/:provider/callback' }) // absolute, one whole ':provider' segment, no other parameter, no ./../empty/encoded segment, not the start path",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Counted from the end so a router mounting the routes under a prefix still finds the segment —
|
|
31
|
+
* the default's `parts.length - 2` before this file existed.
|
|
32
|
+
*/
|
|
33
|
+
export function callbackPath(pattern: string = OAUTH_CALLBACK_ROUTE_PATH): CallbackPath {
|
|
34
|
+
if (!pattern.startsWith('/')) throw refuse(pattern, 'is not an absolute path');
|
|
35
|
+
if (/[?#*]/.test(pattern)) throw refuse(pattern, 'carries a query, fragment or wildcard');
|
|
36
|
+
if (pattern === OAUTH_START_ROUTE_PATH) throw refuse(pattern, 'is the start leg’s own path');
|
|
37
|
+
const parts = split(pattern);
|
|
38
|
+
const params = parts.filter((part) => part.includes(':'));
|
|
39
|
+
if (params.length !== 1 || params[0] !== PARAM) {
|
|
40
|
+
throw refuse(pattern, 'must carry exactly one parameter, the whole segment :provider');
|
|
41
|
+
}
|
|
42
|
+
// A browser and a router both see the NORMALISED path: `..`, `.`, `\` and `%2e` move segments,
|
|
43
|
+
// an empty one shifts the count, and `%2f` is a slash to some routers and not to others.
|
|
44
|
+
const sample = pattern.replace(PARAM, 'provider');
|
|
45
|
+
if (
|
|
46
|
+
pattern.includes('%') ||
|
|
47
|
+
pattern.slice(1).split('/').includes('') ||
|
|
48
|
+
new URL(sample, 'https://x.invalid').pathname !== sample
|
|
49
|
+
) {
|
|
50
|
+
throw refuse(pattern, 'changes under URL normalisation, which would move or drop :provider');
|
|
51
|
+
}
|
|
52
|
+
const fromEnd = parts.length - 1 - parts.indexOf(PARAM);
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
pattern,
|
|
55
|
+
pathFor: (provider: string) => pattern.replace(PARAM, provider),
|
|
56
|
+
providerOf(pathname: string) {
|
|
57
|
+
const segments = split(pathname);
|
|
58
|
+
return segments[segments.length - 1 - fromEnd] ?? '';
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|