@wtfalch/auth 0.10.0 → 0.11.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/dist/auth.d.ts +25 -2
- package/dist/auth.js +47 -1
- 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,
|
|
@@ -716,6 +727,7 @@ export function createAuth(input) {
|
|
|
716
727
|
readCookie,
|
|
717
728
|
gate,
|
|
718
729
|
startUrl,
|
|
730
|
+
fresh,
|
|
719
731
|
errorUrl,
|
|
720
732
|
people,
|
|
721
733
|
setPersonActive,
|
|
@@ -783,6 +795,39 @@ const PROMPTS = ['select_account', 'login'];
|
|
|
783
795
|
function promptFrom(raw) {
|
|
784
796
|
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
785
797
|
}
|
|
798
|
+
// A year. Past that the bound is doing nothing, and an absurd value is a
|
|
799
|
+
// caller getting it wrong rather than a request to honour.
|
|
800
|
+
const MAX_AGE_CEILING = 31_536_000;
|
|
801
|
+
/**
|
|
802
|
+
* The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
|
|
803
|
+
* comes off the issuer's clock and is compared against this server's, so a
|
|
804
|
+
* bound checked to the exact second would flip on skew alone.
|
|
805
|
+
*
|
|
806
|
+
* It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
|
|
807
|
+
* the person again; they then spend a second or two coming back, and a check
|
|
808
|
+
* with no slack would find them already stale and send them round again,
|
|
809
|
+
* forever. The tolerance is why the round trip can ever satisfy the bound.
|
|
810
|
+
*/
|
|
811
|
+
const CLOCK_TOLERANCE = 60;
|
|
812
|
+
function maxAgeFrom(raw) {
|
|
813
|
+
if (raw === null || raw.trim() === '')
|
|
814
|
+
return undefined;
|
|
815
|
+
const value = Number(raw);
|
|
816
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
|
|
817
|
+
return undefined;
|
|
818
|
+
return value;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* A bad `maxAge` from app code is a mistake in the app, and the loud failure is
|
|
822
|
+
* the cheap one. Left alone it is silent and expensive: `startUrl` would drop
|
|
823
|
+
* the value, the issuer would be asked for nothing, the person would come back
|
|
824
|
+
* exactly as stale, and the gate would send them again.
|
|
825
|
+
*/
|
|
826
|
+
function assertMaxAge(value) {
|
|
827
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
|
|
828
|
+
throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
786
831
|
function userFrom(claims) {
|
|
787
832
|
const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
|
|
788
833
|
return {
|
|
@@ -794,6 +839,7 @@ function userFrom(claims) {
|
|
|
794
839
|
familyName: str('family_name'),
|
|
795
840
|
preferredUsername: str('preferred_username'),
|
|
796
841
|
organizationId: String(claims[ORG_CLAIM]),
|
|
842
|
+
authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
|
|
797
843
|
claims,
|
|
798
844
|
};
|
|
799
845
|
}
|
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