@wtfalch/auth 0.3.1 → 0.4.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/README.md CHANGED
@@ -60,6 +60,45 @@ 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
+ `silentSignInAvailable()` is what a caller draws from: with no session it
91
+ cannot otherwise tell *about to leave for the issuer* from *asked already, and
92
+ here we are*, and the first case paints a sign-in screen at somebody on their
93
+ way to being signed in.
94
+
95
+ **Every silent attempt happens at most once per tab**, because an issuer
96
+ answering `login_required` returns the person to a page whose load would ask
97
+ again. `forget()` sets the same mark, so a sign-out does not undo itself.
98
+
99
+ `completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
100
+ answer to a silent attempt, not a failure — or `error`.
101
+
63
102
  ## Configuration
64
103
 
65
104
  | | |
@@ -0,0 +1,111 @@
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
+ * Whether a silent attempt is still available in this tab: no session held,
86
+ * and none tried.
87
+ *
88
+ * A caller needs this to know what to *draw*. "No session" alone cannot
89
+ * tell "about to leave for the issuer" from "asked already, and here we
90
+ * are" -- and drawing a sign-in screen in the first case shows somebody a
91
+ * screen saying they are not signed in, for as long as the redirect takes,
92
+ * on their way to being let in. It was a third of a second on the estate's
93
+ * mail client, and it was the thing people noticed.
94
+ */
95
+ silentSignInAvailable(): boolean;
96
+ /**
97
+ * Ask the issuer to authorise without interacting, if it has not been asked
98
+ * in this tab already. Returns false when there was nothing to try — a
99
+ * session is held, or the attempt has been made — and otherwise does not
100
+ * return, because the browser leaves.
101
+ */
102
+ trySilentSignIn(returnTo?: string): Promise<boolean>;
103
+ /** Leave for the issuer, expecting to be asked for credentials. Does not
104
+ * return. */
105
+ signIn(returnTo?: string): Promise<void>;
106
+ /** Finish the flow at the callback path. */
107
+ completeSignIn(search?: string): Promise<SignInOutcome>;
108
+ /** Drop the session, and do not silently take another in this tab. */
109
+ forget(): void;
110
+ }
111
+ export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
@@ -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
+ const silentSignInAvailable = () => !currentSession() && !store.get(TRIED);
120
+ return {
121
+ currentSession,
122
+ silentSignInAvailable,
123
+ async trySilentSignIn(returnTo = window.location.pathname) {
124
+ if (!silentSignInAvailable())
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.3.1",
4
- "description": "Sign in against auth.wtfalch.dev from a Next.js app.",
3
+ "version": "0.4.1",
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,