@boopkit/cap-users 0.4.3 → 0.4.4
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/package.json
CHANGED
|
@@ -49,7 +49,7 @@ Links a user to an auth mechanism via `(domain, key)` compound unique key:
|
|
|
49
49
|
| `email` | email address | `alice@example.com` |
|
|
50
50
|
| `cookie` | session token | `base64url-token` |
|
|
51
51
|
| `api-key` | hashed key | `hashed-base64url` |
|
|
52
|
-
| OAuth provider |
|
|
52
|
+
| OAuth provider, bare — `google`, `microsoft` | provider's stable subject id | `google-sub-id-12345` |
|
|
53
53
|
|
|
54
54
|
Soft-deleted via `endedAt` — revoked identities remain queryable with `includeEnded: true`.
|
|
55
55
|
|
|
@@ -143,23 +143,55 @@ The plaintext key is returned exactly once at creation. Only the hash is stored.
|
|
|
143
143
|
|
|
144
144
|
### OAuth (as client)
|
|
145
145
|
|
|
146
|
-
When a user logs in via Google
|
|
146
|
+
When a user logs in via Google or Microsoft, the app is an OAuth *client*. The provider proves identity; the app stores the result.
|
|
147
|
+
|
|
148
|
+
These rows are what the `oauth-client` capability writes. It writes them through the services below and adds no user model of its own, so everything on this page still describes the whole truth about a user after you install it — and once installed, its own [`oauth-client.ref.md`](oauth-client.ref.md) covers the sign-in routes, the provider registry, and token refresh in depth.
|
|
147
149
|
|
|
148
150
|
| Layer | What happens |
|
|
149
151
|
|-------|-------------|
|
|
150
152
|
| **Identity** | `domain: "google", key: "google-sub-id-12345"` — maps provider ID to user |
|
|
151
|
-
| **Authentication** | `{ strategy: "oauth-google", accepted: true,
|
|
153
|
+
| **Authentication** | `{ strategy: "oauth-google", accepted: true, access_token: "at_...", refresh_token: "rt_...", access_token_expires_at: 1756..., oauth_profile: {...} }` — delegated credentials stored |
|
|
152
154
|
| **Credentials** | None — OAuth credentials are delegated, stored in the authentication entry |
|
|
153
155
|
| **Authorization** | Created on login, tied to the session cookie — same as password login |
|
|
154
156
|
|
|
155
157
|
OAuth tokens (`refresh_token`, `access_token`) are **delegated credentials** — tokens held from the provider to act on the user's behalf. They are NOT how the app recognizes the user on subsequent requests. Users are recognized via cookies, same as password login.
|
|
156
158
|
|
|
157
|
-
**
|
|
159
|
+
**The identity key is the provider's stable subject identifier** — Google's `sub`, Microsoft's directory object id.
|
|
160
|
+
It is never the email address or the UPN, because both are reassignable and keying on one hands a recycled address the previous holder's account.
|
|
161
|
+
|
|
162
|
+
**The bare identity domain and the prefixed strategy are deliberate, not an oversight.**
|
|
163
|
+
The identity domain is `google` — bare, like `cookie`, `email`, and `api-key`.
|
|
164
|
+
The authentication strategy is `oauth-google` — prefixed, like `local` and `api-key`, where the prefix is what marks a delegated method rather than a locally verified one.
|
|
165
|
+
The two pairs that ship are `google` / `oauth-google` and `microsoft` / `oauth-microsoft`.
|
|
166
|
+
The domain answers *whose account this is*; the strategy answers *how the user authenticates*.
|
|
167
|
+
Those are different questions, and each vocabulary is consistent within itself.
|
|
168
|
+
Do not normalize one to the other. The bare domain is also what lets an app that already stores `(domain: provider_name, key: profile.id)` adopt the `oauth-client` capability without rewriting a single identity row.
|
|
169
|
+
|
|
170
|
+
**OAuth login flow:** `GET /auth/:provider` mints a random nonce and stores it, the PKCE verifier, and the validated return path in a short-lived httpOnly cookie → provider callback with auth code → the nonce is compared to the cookie in constant time, failing closed → exchange the code for tokens → reconcile the provider identity, the email identity, and the anonymous cookie user to a single user, creating one if none exists → store tokens in the authentication entry with an absolute expiry from the provider's `expires_in` → rotate session → create per-device grant.
|
|
171
|
+
|
|
172
|
+
Two of those steps refuse rather than resolve. A provider account and an email address owned by two different users leaves both accounts untouched for a person to sort out, and an unverified provider email may not claim an existing account.
|
|
173
|
+
|
|
174
|
+
A third withholds rather than refuses, and it is the one that shapes what is in the identity table.
|
|
175
|
+
When the provider reports an address it has not verified and *nobody owns it yet*, the sign-in proceeds on the provider identity alone: no `email` identity row is written, and only the profile keeps the claim, labelled unverified.
|
|
176
|
+
The identity row — not the profile — is what resolves a user on a later sign-in, so writing one from an unverified claim would let someone squat an address and be handed the victim when the victim's genuinely verified sign-in matched it.
|
|
177
|
+
The address is not stranded: when the same provider account later reports it verified, the email identity is attached then.
|
|
178
|
+
|
|
179
|
+
Provider tokens are stored **unencrypted**, like everything else in the `authentication` property. They are bearer credentials the app must replay verbatim to the provider, so they cannot be hashed the way cookies and API keys are. They are protected by database access control, and by never being logged, rendered, or returned in a response body.
|
|
158
180
|
|
|
159
181
|
### TOTP / Second Factor
|
|
160
182
|
|
|
161
183
|
TOTP upgrades trust on the current device. It does NOT rotate the session — the user already proved identity at login (which rotated the session). TOTP is additional proof on the same session, not a new authentication source.
|
|
162
184
|
|
|
185
|
+
### Telling the user why a sign-in did not happen
|
|
186
|
+
|
|
187
|
+
`views/login.hbs` renders one error slot, `{{#if errorMessage}}`, and two paths fill it.
|
|
188
|
+
|
|
189
|
+
A failed password POST fills it directly from `handleLogin`'s catch, with one message for both a wrong password and an unknown address — the no-enumeration rule.
|
|
190
|
+
|
|
191
|
+
A **redirect** fills it by carrying a reason code in `?error=`, which `showLogin` turns into copy through `LOGIN_ERROR_MESSAGES` in `src/controllers/auth.ts`. The query value is never rendered: a code the map does not know falls back to `DEFAULT_LOGIN_ERROR_MESSAGE`, so the page cannot reflect a caller's string and a reason the map has not learned yet still says something rather than rendering a silent page. Only an absent or empty `?error=` leaves the slot closed.
|
|
192
|
+
|
|
193
|
+
Shipped codes are `session_expired`, `auth_required`, and `signin_failed`. Add a row to the map to teach the page a new one — including the ones a capability's own failure redirect uses. `OAUTH_SIGNIN_FAILURE_PATH` defaults to `/login`, so every OAuth refusal lands on this slot.
|
|
194
|
+
|
|
163
195
|
---
|
|
164
196
|
|
|
165
197
|
## Logout vs. Forget
|
|
@@ -21,6 +21,73 @@ const FIELD_ERROR_MAP: Record<string, string> = {
|
|
|
21
21
|
passwordConfirmation: 'password_mismatch',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Plain-language copy for the `?error=` reason codes a redirect can carry to
|
|
26
|
+
* `GET /login`.
|
|
27
|
+
*
|
|
28
|
+
* A redirect can only hand the login page a short machine token — a session
|
|
29
|
+
* that expired, a gate that turned the visitor away, a provider sign-in that
|
|
30
|
+
* was refused. This map is where that token becomes a sentence a person can
|
|
31
|
+
* act on, and it is the only thing `showLogin` will put in the error slot.
|
|
32
|
+
*
|
|
33
|
+
* Codes are never echoed. An unrecognised value renders
|
|
34
|
+
* `DEFAULT_LOGIN_ERROR_MESSAGE` instead, so the page cannot be turned into a
|
|
35
|
+
* reflection point for whatever a caller puts in the query string, and a
|
|
36
|
+
* redirect whose code this map has not learned yet still says *something*
|
|
37
|
+
* rather than landing the visitor on a silent page.
|
|
38
|
+
*
|
|
39
|
+
* Add a row here to teach the page a new reason; the fallback keeps working
|
|
40
|
+
* until you do.
|
|
41
|
+
*/
|
|
42
|
+
export const LOGIN_ERROR_MESSAGES: Record<string, string> = {
|
|
43
|
+
session_expired: 'Your session has expired. Please log in again.',
|
|
44
|
+
auth_required: 'Please log in to continue.',
|
|
45
|
+
signin_failed: 'We could not complete that sign-in. Please try again.',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Teach the login page a reason code it does not ship with.
|
|
50
|
+
*
|
|
51
|
+
* `LOGIN_ERROR_MESSAGES` above is deliberately extensible — a redirect that
|
|
52
|
+
* carries a reason is not always one cap-users itself issued. A capability that
|
|
53
|
+
* turns visitors away at its own door (a provider sign-in refused for a reason
|
|
54
|
+
* only it can name) registers its copy here at boot, rather than each such
|
|
55
|
+
* capability inventing a second error slot on the page.
|
|
56
|
+
*
|
|
57
|
+
* Additive and idempotent: registering the same code twice keeps the last
|
|
58
|
+
* writer's copy, and a blank or non-string message is ignored so a misconfigured
|
|
59
|
+
* override cannot blank out a message the page needs to show. Nothing here can
|
|
60
|
+
* *remove* a shipped code.
|
|
61
|
+
*/
|
|
62
|
+
export function registerLoginErrorMessages(messages: Record<string, string>): void {
|
|
63
|
+
for (const [code, message] of Object.entries(messages)) {
|
|
64
|
+
if (typeof message === 'string' && message.trim() !== '') {
|
|
65
|
+
LOGIN_ERROR_MESSAGES[code] = message.trim();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Shown for a reason code this page does not recognise. */
|
|
71
|
+
export const DEFAULT_LOGIN_ERROR_MESSAGE =
|
|
72
|
+
'We could not complete that sign-in. Please try again.';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Turn a `?error=` query value into copy, or into nothing at all.
|
|
76
|
+
*
|
|
77
|
+
* Only an absent or empty value closes the view's `{{#if errorMessage}}` slot.
|
|
78
|
+
* Anything else — an unknown code, or the array Express hands back for a
|
|
79
|
+
* repeated parameter — renders the generic message, because the presence of
|
|
80
|
+
* the parameter is itself the signal that something went wrong, and a page
|
|
81
|
+
* that stays silent about it is the defect this function exists to close.
|
|
82
|
+
*
|
|
83
|
+
* The caller's own string is never returned, whatever it contains.
|
|
84
|
+
*/
|
|
85
|
+
export function loginErrorMessage(code: unknown): string | undefined {
|
|
86
|
+
if (code === undefined || code === null || code === '') return undefined;
|
|
87
|
+
if (typeof code !== 'string') return DEFAULT_LOGIN_ERROR_MESSAGE;
|
|
88
|
+
return LOGIN_ERROR_MESSAGES[code] ?? DEFAULT_LOGIN_ERROR_MESSAGE;
|
|
89
|
+
}
|
|
90
|
+
|
|
24
91
|
export interface AuthControllerDeps {
|
|
25
92
|
authService: AuthService;
|
|
26
93
|
}
|
|
@@ -44,7 +111,10 @@ export function createAuthController(deps: AuthControllerDeps) {
|
|
|
44
111
|
layout: 'main',
|
|
45
112
|
action: '/login',
|
|
46
113
|
next,
|
|
47
|
-
|
|
114
|
+
// `errorMessage` is the name login.hbs reads. Anything else renders
|
|
115
|
+
// nothing, which is how a redirect-carrying-a-reason ends up on a silent
|
|
116
|
+
// page (boop-94l).
|
|
117
|
+
errorMessage: loginErrorMessage(req.query.error),
|
|
48
118
|
});
|
|
49
119
|
}
|
|
50
120
|
|
package/scaffold/views/login.hbs
CHANGED
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
<button class="auth-submit" type="submit">Log In</button>
|
|
22
22
|
</form>
|
|
23
23
|
|
|
24
|
+
{{!-- The return path goes into a query string here, so it needs cap-web's
|
|
25
|
+
`urlencode`: Handlebars escapes for HTML, and an unencoded `&` in the
|
|
26
|
+
path would end the parameter. The hidden field above is a form value,
|
|
27
|
+
not a URL — HTML escaping is the right and only encoding there. --}}
|
|
24
28
|
<div class="auth-alt">
|
|
25
|
-
Don't have an account? <a href="/signup{{#if next}}?next={{next}}{{/if}}">Sign up</a>
|
|
29
|
+
Don't have an account? <a href="/signup{{#if next}}?next={{urlencode next}}{{/if}}">Sign up</a>
|
|
26
30
|
</div>
|
|
27
31
|
</section>
|
|
28
32
|
</div>
|
|
@@ -22,8 +22,12 @@
|
|
|
22
22
|
<button class="auth-submit" type="submit">Sign Up</button>
|
|
23
23
|
</form>
|
|
24
24
|
|
|
25
|
+
{{!-- The return path goes into a query string here, so it needs cap-web's
|
|
26
|
+
`urlencode`: Handlebars escapes for HTML, and an unencoded `&` in the
|
|
27
|
+
path would end the parameter. The hidden field above is a form value,
|
|
28
|
+
not a URL — HTML escaping is the right and only encoding there. --}}
|
|
25
29
|
<div class="auth-alt">
|
|
26
|
-
Already have an account? <a href="/login{{#if next}}?next={{next}}{{/if}}">Log in</a>
|
|
30
|
+
Already have an account? <a href="/login{{#if next}}?next={{urlencode next}}{{/if}}">Log in</a>
|
|
27
31
|
</div>
|
|
28
32
|
</section>
|
|
29
33
|
</div>
|