@wtfalch/auth 0.10.0 → 0.11.1
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/dist/auth.d.ts +25 -2
- package/dist/auth.js +68 -11
- package/dist/index.d.ts +1 -1
- package/dist/next.d.ts +22 -2
- package/dist/next.js +32 -1
- package/dist/oidc.d.ts +5 -2
- package/dist/oidc.js +2 -1
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -15,6 +15,16 @@ export interface User {
|
|
|
15
15
|
familyName: string | null;
|
|
16
16
|
preferredUsername: string | null;
|
|
17
17
|
organizationId: string;
|
|
18
|
+
/**
|
|
19
|
+
* When the person last proved who they were to the issuer, in seconds since
|
|
20
|
+
* the epoch, from the id token's `auth_time`. Null when the issuer did not
|
|
21
|
+
* send it, which OIDC allows unless the request carried `max_age` -- so a
|
|
22
|
+
* null is not evidence of freshness and `fresh()` treats it as stale.
|
|
23
|
+
*
|
|
24
|
+
* A refresh does not move it. It is when they authenticated, not when the
|
|
25
|
+
* token was minted.
|
|
26
|
+
*/
|
|
27
|
+
authTime: number | null;
|
|
18
28
|
claims: JWTPayload;
|
|
19
29
|
}
|
|
20
30
|
export interface ReadResult {
|
|
@@ -46,6 +56,8 @@ export type Gate = {
|
|
|
46
56
|
reason?: 'account_changed' | 'unavailable';
|
|
47
57
|
};
|
|
48
58
|
export type Intent = 'login' | 'register';
|
|
59
|
+
/** Seconds. `0` makes the issuer re-authenticate whatever it did a moment ago. */
|
|
60
|
+
export type MaxAge = number;
|
|
49
61
|
/** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
|
|
50
62
|
export type Prompt = 'select_account' | 'login';
|
|
51
63
|
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
@@ -230,8 +242,19 @@ export interface Auth {
|
|
|
230
242
|
refresh?: boolean;
|
|
231
243
|
}): Promise<ReadResult>;
|
|
232
244
|
gate(request: Request, rules?: GateRules): Promise<Gate>;
|
|
233
|
-
/**
|
|
234
|
-
|
|
245
|
+
/**
|
|
246
|
+
* `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded.
|
|
247
|
+
* `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
|
|
248
|
+
* last authentication is older than that many seconds.
|
|
249
|
+
*/
|
|
250
|
+
startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
|
|
251
|
+
/**
|
|
252
|
+
* Whether `user` authenticated within `maxAge` seconds. A user with no
|
|
253
|
+
* `authTime` is never fresh: the issuer only has to send the claim when the
|
|
254
|
+
* request asked for it, so its absence says nothing, and the safe reading of
|
|
255
|
+
* nothing is "send them back".
|
|
256
|
+
*/
|
|
257
|
+
fresh(user: User | null, maxAge: MaxAge): boolean;
|
|
235
258
|
/**
|
|
236
259
|
* Where to send somebody when a sign-in step failed for a reason they
|
|
237
260
|
* cannot fix by retrying it automatically: `onError` with `?auth_error=`,
|
package/dist/auth.js
CHANGED
|
@@ -74,7 +74,7 @@ export function createAuth(input) {
|
|
|
74
74
|
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
75
75
|
};
|
|
76
76
|
const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
|
|
77
|
-
const startUrl = (next, intent = 'login', prompt) => {
|
|
77
|
+
const startUrl = (next, intent = 'login', prompt, maxAge) => {
|
|
78
78
|
const url = new URL(`${options().basePath}/start`, options().appUrl);
|
|
79
79
|
const path = safeNextPath(next, '');
|
|
80
80
|
if (path)
|
|
@@ -83,8 +83,18 @@ export function createAuth(input) {
|
|
|
83
83
|
url.searchParams.set('intent', 'register');
|
|
84
84
|
if (prompt)
|
|
85
85
|
url.searchParams.set('prompt', prompt);
|
|
86
|
+
if (maxAge !== undefined) {
|
|
87
|
+
assertMaxAge(maxAge);
|
|
88
|
+
url.searchParams.set('max_age', String(maxAge));
|
|
89
|
+
}
|
|
86
90
|
return url.href;
|
|
87
91
|
};
|
|
92
|
+
const fresh = (user, maxAge) => {
|
|
93
|
+
assertMaxAge(maxAge);
|
|
94
|
+
if (!user || user.authTime === null)
|
|
95
|
+
return false;
|
|
96
|
+
return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
|
|
97
|
+
};
|
|
88
98
|
const start = async (request) => {
|
|
89
99
|
if (!isNavigation(request))
|
|
90
100
|
return new Response('Unauthorized', { status: 401 });
|
|
@@ -98,6 +108,7 @@ export function createAuth(input) {
|
|
|
98
108
|
const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
|
|
99
109
|
create: params.get('intent') === 'register',
|
|
100
110
|
prompt: promptFrom(params.get('prompt')),
|
|
111
|
+
maxAge: maxAgeFrom(params.get('max_age')),
|
|
101
112
|
});
|
|
102
113
|
const transaction = await sealTransaction(options(), {
|
|
103
114
|
st: state,
|
|
@@ -164,11 +175,21 @@ export function createAuth(input) {
|
|
|
164
175
|
/**
|
|
165
176
|
* Where a sign-out lands: the issuer's own logout, so its session ends and
|
|
166
177
|
* the next sign-in asks who again, rather than straight back to
|
|
167
|
-
* `afterLogout`. What this function can see fail -- no
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
178
|
+
* `afterLogout`. What this function can see fail -- no `end_session_endpoint`
|
|
179
|
+
* published, the issuer not answering -- falls back to the plain redirect a
|
|
180
|
+
* sign-out has always ended with. Losing the issuer's co-operation is never
|
|
181
|
+
* a reason to fail somebody's sign-out.
|
|
182
|
+
*
|
|
183
|
+
* Having no id token is the one failure that does not fall back, because the
|
|
184
|
+
* plain redirect leaves the issuer's session standing and the next sign-in is
|
|
185
|
+
* silent. `end_session` cannot rescue it either: without a hint the issuer
|
|
186
|
+
* takes its v1 branch and ends v1 user-agent sessions, of which a v2 sign-in
|
|
187
|
+
* has none. Routing a hint-less `end_session` to the hosted login instead
|
|
188
|
+
* needs the instance's `loginV2.required`, which overrides every app's own
|
|
189
|
+
* `loginVersion.loginV2.baseUri` with the instance's -- and each app here
|
|
190
|
+
* hosts its own sign-in at `/auth`, so that flag is not available to us.
|
|
191
|
+
* The hosted login's `/logout` sits at the issuer's origin, where
|
|
192
|
+
* `scripts/provision.mjs` points the instance's `loginV2.baseUri`.
|
|
172
193
|
*
|
|
173
194
|
* `afterLogout` not being a registered `post_logout_redirect_uri` is a
|
|
174
195
|
* failure this function cannot see: that request reaches the issuer, which
|
|
@@ -180,11 +201,12 @@ export function createAuth(input) {
|
|
|
180
201
|
*/
|
|
181
202
|
const endIssuerSession = async (idToken, cookies) => {
|
|
182
203
|
const afterLogout = new URL(options().afterLogout, options().appUrl).href;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
204
|
+
if (!idToken) {
|
|
205
|
+
return redirect(new URL('/logout', options().issuer).href, cookies, 303);
|
|
206
|
+
}
|
|
207
|
+
const target = await oidc()
|
|
208
|
+
.endSessionUrl(idToken, afterLogout)
|
|
209
|
+
.catch(() => null);
|
|
188
210
|
return redirect(target?.href ?? afterLogout, cookies, 303);
|
|
189
211
|
};
|
|
190
212
|
const logout = async (request) => {
|
|
@@ -716,6 +738,7 @@ export function createAuth(input) {
|
|
|
716
738
|
readCookie,
|
|
717
739
|
gate,
|
|
718
740
|
startUrl,
|
|
741
|
+
fresh,
|
|
719
742
|
errorUrl,
|
|
720
743
|
people,
|
|
721
744
|
setPersonActive,
|
|
@@ -783,6 +806,39 @@ const PROMPTS = ['select_account', 'login'];
|
|
|
783
806
|
function promptFrom(raw) {
|
|
784
807
|
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
785
808
|
}
|
|
809
|
+
// A year. Past that the bound is doing nothing, and an absurd value is a
|
|
810
|
+
// caller getting it wrong rather than a request to honour.
|
|
811
|
+
const MAX_AGE_CEILING = 31_536_000;
|
|
812
|
+
/**
|
|
813
|
+
* The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
|
|
814
|
+
* comes off the issuer's clock and is compared against this server's, so a
|
|
815
|
+
* bound checked to the exact second would flip on skew alone.
|
|
816
|
+
*
|
|
817
|
+
* It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
|
|
818
|
+
* the person again; they then spend a second or two coming back, and a check
|
|
819
|
+
* with no slack would find them already stale and send them round again,
|
|
820
|
+
* forever. The tolerance is why the round trip can ever satisfy the bound.
|
|
821
|
+
*/
|
|
822
|
+
const CLOCK_TOLERANCE = 60;
|
|
823
|
+
function maxAgeFrom(raw) {
|
|
824
|
+
if (raw === null || raw.trim() === '')
|
|
825
|
+
return undefined;
|
|
826
|
+
const value = Number(raw);
|
|
827
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
|
|
828
|
+
return undefined;
|
|
829
|
+
return value;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* A bad `maxAge` from app code is a mistake in the app, and the loud failure is
|
|
833
|
+
* the cheap one. Left alone it is silent and expensive: `startUrl` would drop
|
|
834
|
+
* the value, the issuer would be asked for nothing, the person would come back
|
|
835
|
+
* exactly as stale, and the gate would send them again.
|
|
836
|
+
*/
|
|
837
|
+
function assertMaxAge(value) {
|
|
838
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
|
|
839
|
+
throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
786
842
|
function userFrom(claims) {
|
|
787
843
|
const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
|
|
788
844
|
return {
|
|
@@ -794,6 +850,7 @@ function userFrom(claims) {
|
|
|
794
850
|
familyName: str('family_name'),
|
|
795
851
|
preferredUsername: str('preferred_username'),
|
|
796
852
|
organizationId: String(claims[ORG_CLAIM]),
|
|
853
|
+
authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
|
|
797
854
|
claims,
|
|
798
855
|
};
|
|
799
856
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
2
2
|
export { type AuthOptions, ISSUER } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
package/dist/next.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
2
|
+
import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
3
3
|
import type { AuthOptions } from './config.js';
|
|
4
4
|
import type { SessionRecord } from './sessions.js';
|
|
5
5
|
type SearchParams = Record<string, string | string[] | undefined>;
|
|
@@ -22,6 +22,25 @@ export interface NextAuth {
|
|
|
22
22
|
getUser: () => Promise<User | null>;
|
|
23
23
|
/** The user, or a redirect to sign in with `next` to come back to. */
|
|
24
24
|
requireUser: (next?: string) => Promise<User>;
|
|
25
|
+
/**
|
|
26
|
+
* The user, but only if they authenticated within `maxAge` seconds;
|
|
27
|
+
* otherwise a redirect that makes the issuer ask them again. Put it in front
|
|
28
|
+
* of an action somebody holding a borrowed laptop must not be able to take:
|
|
29
|
+
* changing the password, changing the email, removing a passkey.
|
|
30
|
+
*
|
|
31
|
+
* Somebody whose id token carries no `auth_time` is sent back too. The
|
|
32
|
+
* issuer only has to send that claim when the request asked for it, so its
|
|
33
|
+
* absence proves nothing, and the first round trip fixes it for good.
|
|
34
|
+
*
|
|
35
|
+
* **Pass the page's `searchParams`.** They carry the mark that says this
|
|
36
|
+
* person has already been sent once; without them a person the issuer will
|
|
37
|
+
* not re-authenticate is sent round the same loop for ever. See the
|
|
38
|
+
* implementation for why that is not hypothetical here.
|
|
39
|
+
*/
|
|
40
|
+
requireFreshUser: (maxAge: MaxAge, opts?: {
|
|
41
|
+
next?: string;
|
|
42
|
+
searchParams?: SearchParams | Promise<SearchParams>;
|
|
43
|
+
}) => Promise<User>;
|
|
25
44
|
/**
|
|
26
45
|
* For the app's login and register pages: the auth request the issuer sent
|
|
27
46
|
* the browser with, or a redirect that starts one. Pass the page's searchParams.
|
|
@@ -29,6 +48,7 @@ export interface NextAuth {
|
|
|
29
48
|
authRequest: (searchParams: SearchParams | Promise<SearchParams>, opts?: {
|
|
30
49
|
next?: string;
|
|
31
50
|
intent?: Intent;
|
|
51
|
+
prompt?: Prompt;
|
|
32
52
|
}) => Promise<AuthRequest>;
|
|
33
53
|
/** For a server action: redirects into the app on success, returns the error otherwise. */
|
|
34
54
|
signIn: (input: {
|
|
@@ -137,4 +157,4 @@ export interface NextAuth {
|
|
|
137
157
|
}>;
|
|
138
158
|
}
|
|
139
159
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
140
|
-
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, SessionRecord, User, };
|
|
160
|
+
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
package/dist/next.js
CHANGED
|
@@ -5,6 +5,12 @@ import { createAuth, } from './auth.js';
|
|
|
5
5
|
import { BrokerError } from './broker.js';
|
|
6
6
|
import { withCookies } from './cookies.js';
|
|
7
7
|
import { AuthError } from './oidc.js';
|
|
8
|
+
/** On the page a step-up returns to: this person has been sent once already. */
|
|
9
|
+
const STEP_UP_PARAM = 'auth_stepup';
|
|
10
|
+
function marked(next) {
|
|
11
|
+
const path = next ?? '/';
|
|
12
|
+
return `${path}${path.includes('?') ? '&' : '?'}${STEP_UP_PARAM}=1`;
|
|
13
|
+
}
|
|
8
14
|
/**
|
|
9
15
|
* Which of the two failures it was, in the vocabulary `auth_error` already
|
|
10
16
|
* uses. A refusal under 500 is about this request — the broker says unknown,
|
|
@@ -63,6 +69,30 @@ export function nextAuth(options) {
|
|
|
63
69
|
redirect(auth.startUrl(next));
|
|
64
70
|
return user;
|
|
65
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* One redirect, never two. The second one is the loop.
|
|
74
|
+
*
|
|
75
|
+
* The gate has exactly the shape `authRequest` warns about below, and the
|
|
76
|
+
* same price: send somebody to the issuer because they are stale, and if
|
|
77
|
+
* they come back no fresher, the next render sends them again. Nothing
|
|
78
|
+
* errors and nothing is logged. An issuer that declines to honour `max_age`,
|
|
79
|
+
* or one that omits `auth_time` from the token it mints afterwards, turns
|
|
80
|
+
* the gate into that loop for every person who reaches it.
|
|
81
|
+
*
|
|
82
|
+
* So the redirect marks the return address, and a person who arrives already
|
|
83
|
+
* marked gets an `AuthError` instead of another redirect. The app shows them
|
|
84
|
+
* something and they click. A human click cannot loop.
|
|
85
|
+
*/
|
|
86
|
+
const requireFreshUser = async (maxAge, opts = {}) => {
|
|
87
|
+
const user = await requireUser(opts.next);
|
|
88
|
+
if (auth.fresh(user, maxAge))
|
|
89
|
+
return user;
|
|
90
|
+
const params = opts.searchParams ? await opts.searchParams : undefined;
|
|
91
|
+
if (params?.[STEP_UP_PARAM] !== undefined) {
|
|
92
|
+
throw new AuthError('stale', 'Could not confirm a recent sign-in');
|
|
93
|
+
}
|
|
94
|
+
redirect(auth.startUrl(marked(opts.next), 'login', undefined, maxAge));
|
|
95
|
+
};
|
|
66
96
|
/**
|
|
67
97
|
* The auth request the issuer sent the browser here with.
|
|
68
98
|
*
|
|
@@ -93,7 +123,7 @@ export function nextAuth(options) {
|
|
|
93
123
|
// Nobody has started a flow yet. This cannot loop: the issuer always
|
|
94
124
|
// sends the browser back carrying an id.
|
|
95
125
|
if (!id)
|
|
96
|
-
redirect(auth.startUrl(opts.next, opts.intent));
|
|
126
|
+
redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
|
|
97
127
|
try {
|
|
98
128
|
return await auth.authRequest(id);
|
|
99
129
|
}
|
|
@@ -220,6 +250,7 @@ export function nextAuth(options) {
|
|
|
220
250
|
proxy,
|
|
221
251
|
getUser,
|
|
222
252
|
requireUser,
|
|
253
|
+
requireFreshUser,
|
|
223
254
|
authRequest,
|
|
224
255
|
signIn,
|
|
225
256
|
signUp,
|
package/dist/oidc.d.ts
CHANGED
|
@@ -26,9 +26,10 @@ export declare class Oidc {
|
|
|
26
26
|
private config;
|
|
27
27
|
private keys;
|
|
28
28
|
get redirectUri(): URL;
|
|
29
|
-
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt }?: {
|
|
29
|
+
authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, }?: {
|
|
30
30
|
create?: boolean;
|
|
31
31
|
prompt?: 'select_account' | 'login';
|
|
32
|
+
maxAge?: number;
|
|
32
33
|
}): Promise<URL>;
|
|
33
34
|
exchange(callbackParams: URLSearchParams, transaction: {
|
|
34
35
|
state: string;
|
|
@@ -48,7 +49,9 @@ export declare class Oidc {
|
|
|
48
49
|
}
|
|
49
50
|
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
|
|
50
51
|
/** No session at all -- distinct from `account_changed`/`unavailable`, which `requireUser` already covers by redirecting instead. */
|
|
51
|
-
| 'unauthenticated'
|
|
52
|
+
| 'unauthenticated'
|
|
53
|
+
/** Came back from a step-up no fresher than it went. Never redirect on this: that is the loop. */
|
|
54
|
+
| 'stale';
|
|
52
55
|
export declare class AuthError extends Error {
|
|
53
56
|
readonly reason: AuthErrorReason;
|
|
54
57
|
constructor(reason: AuthErrorReason, message: string);
|
package/dist/oidc.js
CHANGED
|
@@ -62,9 +62,10 @@ export class Oidc {
|
|
|
62
62
|
get redirectUri() {
|
|
63
63
|
return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
|
|
64
64
|
}
|
|
65
|
-
async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt } = {}) {
|
|
65
|
+
async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, } = {}) {
|
|
66
66
|
return client.buildAuthorizationUrl(await this.config(), {
|
|
67
67
|
...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
|
|
68
|
+
...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
|
|
68
69
|
redirect_uri: this.redirectUri.href,
|
|
69
70
|
scope: scopeFor(this.options.organizationId),
|
|
70
71
|
state,
|
package/package.json
CHANGED