@wtfalch/auth 0.3.1 → 0.4.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/README.md +34 -0
- package/dist/browser.d.ts +99 -0
- package/dist/browser.js +202 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -60,6 +60,40 @@ Auth-request lookup failures lead to the configured error page with
|
|
|
60
60
|
`auth_error=request` or `auth_error=unavailable`. That page should show a
|
|
61
61
|
retry link; it must not automatically restart authorization.
|
|
62
62
|
|
|
63
|
+
## In a browser app
|
|
64
|
+
|
|
65
|
+
`@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
|
|
66
|
+
runs in the browser rather than on a server, and **silent single sign-on
|
|
67
|
+
across the estate's subdomains**.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { createBrowserAuth } from '@wtfalch/auth/browser';
|
|
71
|
+
|
|
72
|
+
const auth = createBrowserAuth({
|
|
73
|
+
issuer: 'https://auth.wtfalch.dev',
|
|
74
|
+
clientId: '…', // this app's client id at the issuer
|
|
75
|
+
callbackPath: '/signed-in', // when `/auth/callback` is not yours to host
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// On load, with no token: ask the issuer without interacting. Either it
|
|
79
|
+
// answers with a code — a flash and the app is signed in — or it says
|
|
80
|
+
// `login_required` and the app shows its own button.
|
|
81
|
+
if (!auth.currentSession()) await auth.trySilentSignIn();
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Why not one cookie for `.<apex>`.** The server session above is a `__Host-`
|
|
85
|
+
cookie, which by specification carries no `Domain`: host-only, and that is what
|
|
86
|
+
stops one subdomain writing a session the next one trusts. Single sign-on comes
|
|
87
|
+
from the **issuer's** session instead — each origin takes its own token from
|
|
88
|
+
it, and the person is asked for credentials once.
|
|
89
|
+
|
|
90
|
+
**Every silent attempt happens at most once per tab**, because an issuer
|
|
91
|
+
answering `login_required` returns the person to a page whose load would ask
|
|
92
|
+
again. `forget()` sets the same mark, so a sign-out does not undo itself.
|
|
93
|
+
|
|
94
|
+
`completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
|
|
95
|
+
answer to a silent attempt, not a failure — or `error`.
|
|
96
|
+
|
|
63
97
|
## Configuration
|
|
64
98
|
|
|
65
99
|
| | |
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in from a browser app, and being *already* signed in when another
|
|
3
|
+
* subdomain did it.
|
|
4
|
+
*
|
|
5
|
+
* The rest of this package is for a server: it holds a session in a cookie and
|
|
6
|
+
* the person never sees a token. A browser app cannot do that — whatever it
|
|
7
|
+
* holds, the person holding the browser holds too — so this is a public client
|
|
8
|
+
* using PKCE, the flow designed for exactly that.
|
|
9
|
+
*
|
|
10
|
+
* **Why a session cannot simply be shared across subdomains.** The server
|
|
11
|
+
* side's cookie is `__Host-` prefixed, and that prefix is defined as `Secure`,
|
|
12
|
+
* `Path=/` and *no* `Domain` attribute: host-only, by specification. It is
|
|
13
|
+
* what stops one subdomain writing a session cookie the next one would trust.
|
|
14
|
+
* Widening it to `.<apex>` to get single sign-on would trade that away, so the
|
|
15
|
+
* shared thing is not a cookie: it is the **session at the issuer**. Every
|
|
16
|
+
* origin gets its own token from it, and the person is asked for credentials
|
|
17
|
+
* once.
|
|
18
|
+
*
|
|
19
|
+
* **`prompt=none` is what makes that invisible.** On load, an app with no token
|
|
20
|
+
* asks the issuer to authorise without interacting. Either a code comes back —
|
|
21
|
+
* a redirect out and in, a flash, and the app is signed in — or the issuer
|
|
22
|
+
* answers `login_required`, and the app shows its own sign-in affordance
|
|
23
|
+
* having lost nothing. `trySilentSignIn` is that attempt, `completeSignIn`
|
|
24
|
+
* tells the two apart, and `SilentRefused` is the outcome to draw a button for
|
|
25
|
+
* rather than an error.
|
|
26
|
+
*
|
|
27
|
+
* **A top-level redirect, not a hidden iframe.** The classic silent-auth trick
|
|
28
|
+
* loads the issuer in an iframe, which needs the issuer's cookie in a
|
|
29
|
+
* third-party context — blocked by default in Safari and increasingly
|
|
30
|
+
* elsewhere. It fails *silently* and intermittently, which is the worst
|
|
31
|
+
* failure mode available. A redirect always works and costs a flash.
|
|
32
|
+
*
|
|
33
|
+
* **Every silent attempt is made at most once per tab.** Without that, an
|
|
34
|
+
* issuer answering `login_required` sends the app back to a page whose load
|
|
35
|
+
* asks again, forever, and the loop is invisible: the address bar settles and
|
|
36
|
+
* the tab spins. `forget()` sets the same mark, so pressing sign out does not
|
|
37
|
+
* bounce somebody straight back in — a sign-out that undoes itself is not one.
|
|
38
|
+
*/
|
|
39
|
+
export interface BrowserAuthOptions {
|
|
40
|
+
/** The issuer, e.g. `https://auth.wtfalch.dev`. */
|
|
41
|
+
issuer: string;
|
|
42
|
+
/** This app's client id at the issuer. */
|
|
43
|
+
clientId: string;
|
|
44
|
+
/**
|
|
45
|
+
* This app's origin. Read from the browser by default, so one build serves
|
|
46
|
+
* every project's subdomain without being told which it answers on.
|
|
47
|
+
*/
|
|
48
|
+
origin?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Where the issuer sends the person back. `/auth/callback` is the estate's
|
|
51
|
+
* default, and an app that cannot host it there says so — on a Stalwart
|
|
52
|
+
* origin `/auth` belongs to Stalwart and never reaches the app.
|
|
53
|
+
*/
|
|
54
|
+
callbackPath?: string;
|
|
55
|
+
/** Scopes to ask for. `openid` is required; the rest is the app's business. */
|
|
56
|
+
scope?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface BrowserSession {
|
|
59
|
+
accessToken: string;
|
|
60
|
+
/** Epoch milliseconds. */
|
|
61
|
+
expiresAt: number;
|
|
62
|
+
}
|
|
63
|
+
/** What `completeSignIn` found at the callback. */
|
|
64
|
+
export type SignInOutcome = {
|
|
65
|
+
kind: 'signed-in';
|
|
66
|
+
session: BrowserSession;
|
|
67
|
+
returnTo: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The issuer has no session for this person, so a silent attempt could not
|
|
71
|
+
* be honoured. Not an error: it is the answer, and the app should show its
|
|
72
|
+
* sign-in affordance.
|
|
73
|
+
*/
|
|
74
|
+
| {
|
|
75
|
+
kind: 'silent-refused';
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'error';
|
|
78
|
+
error: string;
|
|
79
|
+
};
|
|
80
|
+
export interface BrowserAuth {
|
|
81
|
+
/** The session this tab holds, or null. Expiry is checked with a minute of
|
|
82
|
+
* slack: a token that dies mid-request is worse than one renewed early. */
|
|
83
|
+
currentSession(): BrowserSession | null;
|
|
84
|
+
/**
|
|
85
|
+
* Ask the issuer to authorise without interacting, if it has not been asked
|
|
86
|
+
* in this tab already. Returns false when there was nothing to try — a
|
|
87
|
+
* session is held, or the attempt has been made — and otherwise does not
|
|
88
|
+
* return, because the browser leaves.
|
|
89
|
+
*/
|
|
90
|
+
trySilentSignIn(returnTo?: string): Promise<boolean>;
|
|
91
|
+
/** Leave for the issuer, expecting to be asked for credentials. Does not
|
|
92
|
+
* return. */
|
|
93
|
+
signIn(returnTo?: string): Promise<void>;
|
|
94
|
+
/** Finish the flow at the callback path. */
|
|
95
|
+
completeSignIn(search?: string): Promise<SignInOutcome>;
|
|
96
|
+
/** Drop the session, and do not silently take another in this tab. */
|
|
97
|
+
forget(): void;
|
|
98
|
+
}
|
|
99
|
+
export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in from a browser app, and being *already* signed in when another
|
|
3
|
+
* subdomain did it.
|
|
4
|
+
*
|
|
5
|
+
* The rest of this package is for a server: it holds a session in a cookie and
|
|
6
|
+
* the person never sees a token. A browser app cannot do that — whatever it
|
|
7
|
+
* holds, the person holding the browser holds too — so this is a public client
|
|
8
|
+
* using PKCE, the flow designed for exactly that.
|
|
9
|
+
*
|
|
10
|
+
* **Why a session cannot simply be shared across subdomains.** The server
|
|
11
|
+
* side's cookie is `__Host-` prefixed, and that prefix is defined as `Secure`,
|
|
12
|
+
* `Path=/` and *no* `Domain` attribute: host-only, by specification. It is
|
|
13
|
+
* what stops one subdomain writing a session cookie the next one would trust.
|
|
14
|
+
* Widening it to `.<apex>` to get single sign-on would trade that away, so the
|
|
15
|
+
* shared thing is not a cookie: it is the **session at the issuer**. Every
|
|
16
|
+
* origin gets its own token from it, and the person is asked for credentials
|
|
17
|
+
* once.
|
|
18
|
+
*
|
|
19
|
+
* **`prompt=none` is what makes that invisible.** On load, an app with no token
|
|
20
|
+
* asks the issuer to authorise without interacting. Either a code comes back —
|
|
21
|
+
* a redirect out and in, a flash, and the app is signed in — or the issuer
|
|
22
|
+
* answers `login_required`, and the app shows its own sign-in affordance
|
|
23
|
+
* having lost nothing. `trySilentSignIn` is that attempt, `completeSignIn`
|
|
24
|
+
* tells the two apart, and `SilentRefused` is the outcome to draw a button for
|
|
25
|
+
* rather than an error.
|
|
26
|
+
*
|
|
27
|
+
* **A top-level redirect, not a hidden iframe.** The classic silent-auth trick
|
|
28
|
+
* loads the issuer in an iframe, which needs the issuer's cookie in a
|
|
29
|
+
* third-party context — blocked by default in Safari and increasingly
|
|
30
|
+
* elsewhere. It fails *silently* and intermittently, which is the worst
|
|
31
|
+
* failure mode available. A redirect always works and costs a flash.
|
|
32
|
+
*
|
|
33
|
+
* **Every silent attempt is made at most once per tab.** Without that, an
|
|
34
|
+
* issuer answering `login_required` sends the app back to a page whose load
|
|
35
|
+
* asks again, forever, and the loop is invisible: the address bar settles and
|
|
36
|
+
* the tab spins. `forget()` sets the same mark, so pressing sign out does not
|
|
37
|
+
* bounce somebody straight back in — a sign-out that undoes itself is not one.
|
|
38
|
+
*/
|
|
39
|
+
const TOKEN = 'wtfalch.auth.token';
|
|
40
|
+
const VERIFIER = 'wtfalch.auth.verifier';
|
|
41
|
+
const RETURN = 'wtfalch.auth.return';
|
|
42
|
+
/** Set before a silent attempt leaves, so a refusal cannot become a loop. */
|
|
43
|
+
const TRIED = 'wtfalch.auth.silent-tried';
|
|
44
|
+
/** Storage that never throws. A browser refusing it signs in every visit,
|
|
45
|
+
* which is worse than the alternative but not broken. */
|
|
46
|
+
const store = {
|
|
47
|
+
get(key) {
|
|
48
|
+
try {
|
|
49
|
+
return sessionStorage.getItem(key);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
set(key, value) {
|
|
56
|
+
try {
|
|
57
|
+
sessionStorage.setItem(key, value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* ignored, deliberately: see above */
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
drop(key) {
|
|
64
|
+
try {
|
|
65
|
+
sessionStorage.removeItem(key);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* ignored, deliberately */
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
function base64url(bytes) {
|
|
73
|
+
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
74
|
+
.replace(/\+/g, '-')
|
|
75
|
+
.replace(/\//g, '_')
|
|
76
|
+
.replace(/=+$/, '');
|
|
77
|
+
}
|
|
78
|
+
async function challengeFor(verifier) {
|
|
79
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
80
|
+
return base64url(digest);
|
|
81
|
+
}
|
|
82
|
+
function verifier() {
|
|
83
|
+
// 32 bytes is RFC 7636's recommendation; base64url of it is 43 characters,
|
|
84
|
+
// inside the 43..128 the spec allows.
|
|
85
|
+
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
86
|
+
}
|
|
87
|
+
export function createBrowserAuth(options) {
|
|
88
|
+
const origin = options.origin ?? window.location.origin;
|
|
89
|
+
const callbackPath = options.callbackPath ?? '/auth/callback';
|
|
90
|
+
const scope = options.scope ?? 'openid email profile offline_access';
|
|
91
|
+
const redirectUri = `${origin}${callbackPath}`;
|
|
92
|
+
const authorize = async (prompt, returnTo) => {
|
|
93
|
+
const proof = verifier();
|
|
94
|
+
store.set(VERIFIER, proof);
|
|
95
|
+
store.set(RETURN, returnTo);
|
|
96
|
+
const url = new URL(`${options.issuer}/oauth/v2/authorize`);
|
|
97
|
+
url.searchParams.set('client_id', options.clientId);
|
|
98
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
99
|
+
url.searchParams.set('response_type', 'code');
|
|
100
|
+
url.searchParams.set('scope', scope);
|
|
101
|
+
url.searchParams.set('code_challenge', await challengeFor(proof));
|
|
102
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
103
|
+
if (prompt)
|
|
104
|
+
url.searchParams.set('prompt', prompt);
|
|
105
|
+
window.location.assign(url.toString());
|
|
106
|
+
};
|
|
107
|
+
const currentSession = () => {
|
|
108
|
+
const raw = store.get(TOKEN);
|
|
109
|
+
if (!raw)
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const session = JSON.parse(raw);
|
|
113
|
+
return session.expiresAt - 60_000 > Date.now() ? session : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
currentSession,
|
|
121
|
+
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
122
|
+
if (currentSession())
|
|
123
|
+
return false;
|
|
124
|
+
if (store.get(TRIED))
|
|
125
|
+
return false;
|
|
126
|
+
store.set(TRIED, '1');
|
|
127
|
+
await authorize('none', returnTo);
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
async signIn(returnTo = window.location.pathname) {
|
|
131
|
+
// A deliberate sign-in clears the mark: whatever the issuer said before,
|
|
132
|
+
// the person is asking now.
|
|
133
|
+
store.drop(TRIED);
|
|
134
|
+
await authorize(undefined, returnTo);
|
|
135
|
+
},
|
|
136
|
+
async completeSignIn(search = window.location.search) {
|
|
137
|
+
const params = new URLSearchParams(search);
|
|
138
|
+
const proof = store.get(VERIFIER);
|
|
139
|
+
const returnTo = store.get(RETURN) ?? '/';
|
|
140
|
+
// Removed whatever happens: a code that failed to exchange must not be
|
|
141
|
+
// retried against the same proof, and a stale verifier is what makes the
|
|
142
|
+
// *next* sign-in fail confusingly.
|
|
143
|
+
store.drop(VERIFIER);
|
|
144
|
+
store.drop(RETURN);
|
|
145
|
+
const issuerError = params.get('error');
|
|
146
|
+
if (issuerError) {
|
|
147
|
+
// The three the spec gives for "I would have had to ask them
|
|
148
|
+
// something". Any of them answers a silent attempt rather than
|
|
149
|
+
// failing it.
|
|
150
|
+
if (['login_required', 'interaction_required', 'consent_required'].includes(issuerError)) {
|
|
151
|
+
return { kind: 'silent-refused' };
|
|
152
|
+
}
|
|
153
|
+
return { kind: 'error', error: params.get('error_description') ?? issuerError };
|
|
154
|
+
}
|
|
155
|
+
const code = params.get('code');
|
|
156
|
+
if (!code)
|
|
157
|
+
return { kind: 'error', error: 'the issuer sent no code' };
|
|
158
|
+
if (!proof) {
|
|
159
|
+
return {
|
|
160
|
+
kind: 'error',
|
|
161
|
+
error: 'this tab has no record of starting a sign-in — start again from the app',
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
167
|
+
body: new URLSearchParams({
|
|
168
|
+
grant_type: 'authorization_code',
|
|
169
|
+
client_id: options.clientId,
|
|
170
|
+
redirect_uri: redirectUri,
|
|
171
|
+
code,
|
|
172
|
+
code_verifier: proof,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
const body = await response.text();
|
|
177
|
+
return {
|
|
178
|
+
kind: 'error',
|
|
179
|
+
error: `the issuer refused the code (${response.status}): ${body.slice(0, 200)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const token = (await response.json());
|
|
183
|
+
if (!token.access_token)
|
|
184
|
+
return { kind: 'error', error: 'the issuer returned no access token' };
|
|
185
|
+
const session = {
|
|
186
|
+
accessToken: token.access_token,
|
|
187
|
+
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
188
|
+
};
|
|
189
|
+
store.set(TOKEN, JSON.stringify(session));
|
|
190
|
+
// Signed in: a later silent attempt in this tab is allowed again, which
|
|
191
|
+
// matters when the token expires and the page reloads.
|
|
192
|
+
store.drop(TRIED);
|
|
193
|
+
return { kind: 'signed-in', session, returnTo };
|
|
194
|
+
},
|
|
195
|
+
forget() {
|
|
196
|
+
store.drop(TOKEN);
|
|
197
|
+
// The mark stays set: a sign-out that silently signs you back in is not
|
|
198
|
+
// a sign-out.
|
|
199
|
+
store.set(TRIED, '1');
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sign in against auth.wtfalch.dev
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Sign in against auth.wtfalch.dev: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wtfalch/auth",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"./next": {
|
|
18
18
|
"types": "./dist/next.d.ts",
|
|
19
19
|
"default": "./dist/next.js"
|
|
20
|
+
},
|
|
21
|
+
"./browser": {
|
|
22
|
+
"types": "./dist/browser.d.ts",
|
|
23
|
+
"default": "./dist/browser.js"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"sideEffects": false,
|