@wtfalch/auth 0.1.0 → 0.2.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 +90 -0
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +13 -0
- package/dist/broker.d.ts +6 -0
- package/dist/config.d.ts +22 -3
- package/dist/config.js +18 -1
- package/dist/redirect.js +8 -1
- package/package.json +12 -11
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @wtfalch/auth
|
|
2
|
+
|
|
3
|
+
Sign in against [auth.wtfalch.dev](https://auth.wtfalch.dev) from a Next.js app.
|
|
4
|
+
|
|
5
|
+
A thin OIDC client for one self-hosted ZITADEL instance, plus a client for the
|
|
6
|
+
sign-in service that sits in front of it. Apps host their own sign-in pages: a
|
|
7
|
+
browser never renders a page on the identity service.
|
|
8
|
+
|
|
9
|
+
Built for the wtfalch estate, published because valet consumes it from a
|
|
10
|
+
container and a `file:` path does not survive a Docker build.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
pnpm add @wtfalch/auth
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node 22 or later. `next` is a peer dependency and only the `/next` entry point
|
|
17
|
+
needs it.
|
|
18
|
+
|
|
19
|
+
## Using it
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// src/lib/auth.ts
|
|
23
|
+
import { nextAuth } from '@wtfalch/auth/next';
|
|
24
|
+
|
|
25
|
+
export const { handlers, proxy, getUser, requireUser, signIn } = nextAuth({
|
|
26
|
+
appUrl: process.env.APP_URL,
|
|
27
|
+
clientId: process.env.AUTH_CLIENT_ID,
|
|
28
|
+
organizationId: process.env.AUTH_ORGANIZATION_ID,
|
|
29
|
+
cookieSecret: process.env.AUTH_COOKIE_SECRET,
|
|
30
|
+
appKey: process.env.AUTH_APP_KEY,
|
|
31
|
+
afterLogin: '/portal',
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Mount `handlers` at `app/auth/[...auth]/route.ts`, call `proxy` from your
|
|
36
|
+
middleware for the paths that need a session, and read the person with
|
|
37
|
+
`getUser()` in a server component.
|
|
38
|
+
|
|
39
|
+
`createAuth` from `@wtfalch/auth` is the same thing without the framework, for
|
|
40
|
+
a CLI or a worker that has no router to import.
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
| | |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `appUrl` | This app's origin. Every URL the SDK builds starts here, never from the request's `Host`. |
|
|
47
|
+
| `clientId` | The OIDC client for this origin. Each URL an app runs at is its own client, because the issuer holds one login URL per application. |
|
|
48
|
+
| `organizationId` | Scopes every sign-in, and is checked on every token. |
|
|
49
|
+
| `cookieSecret` | 32 bytes, `openssl rand -base64 32`. One per app. |
|
|
50
|
+
| `appKey` | This app's key for the sign-in service. Needed for anything that checks or creates a credential. |
|
|
51
|
+
|
|
52
|
+
Values are read on first use rather than at import, so `next build` needs none
|
|
53
|
+
of them, and a missing one fails by name rather than mysteriously.
|
|
54
|
+
|
|
55
|
+
**A cookie secret is needed where cookies are, and nowhere else.** An app that
|
|
56
|
+
never seals a session, such as a CLI that sends an invitation, can leave it
|
|
57
|
+
unset: the error waits until something actually reaches for the key. A secret
|
|
58
|
+
that is *supplied and wrong* still throws at startup, because that is a typo
|
|
59
|
+
rather than a deployment that does not need one.
|
|
60
|
+
|
|
61
|
+
## What the app key can do
|
|
62
|
+
|
|
63
|
+
It reaches the sign-in service, which holds ZITADEL's login client. Scoped to
|
|
64
|
+
one organisation and to this app's clients, it can create people in that
|
|
65
|
+
organisation, send mail to an address, and try passwords.
|
|
66
|
+
|
|
67
|
+
It cannot sign in as somebody without their password or their mailbox, cannot
|
|
68
|
+
reach another app, and cannot change anything on the instance. Treat it as a
|
|
69
|
+
secret; it is not a skeleton key.
|
|
70
|
+
|
|
71
|
+
## The session
|
|
72
|
+
|
|
73
|
+
The cookie is the session. There is no session table and no token store, so
|
|
74
|
+
there is nothing to expire, revoke or clean up on the app's side, and
|
|
75
|
+
authority is whatever the app reads for itself on each request. The cookie is
|
|
76
|
+
encrypted with `cookieSecret`, carries `__Host-` and `Secure` on an `https://`
|
|
77
|
+
app URL, and refreshes itself when the id token is close to expiring.
|
|
78
|
+
|
|
79
|
+
`ORG_CLAIM` is enforced on every token: a token from another organisation is
|
|
80
|
+
refused even when the issuer and the signature are good.
|
|
81
|
+
|
|
82
|
+
## Documentation
|
|
83
|
+
|
|
84
|
+
`docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
|
|
85
|
+
full adoption: the files to add, the email flows, invitations, refresh, and the
|
|
86
|
+
checks to run against a real issuer.
|
|
87
|
+
|
|
88
|
+
## Licence
|
|
89
|
+
|
|
90
|
+
Private to the wtfalch estate.
|
package/dist/auth.d.ts
CHANGED
|
@@ -37,9 +37,9 @@ export type Gate = {
|
|
|
37
37
|
cookies: SetCookie[];
|
|
38
38
|
};
|
|
39
39
|
export type Intent = 'login' | 'register';
|
|
40
|
-
export type SignInError = 'invalid_credentials' | 'request' | 'unavailable';
|
|
40
|
+
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
41
41
|
export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
|
|
42
|
-
export type SignUpError = 'email_taken' | 'invalid' | 'request' | 'unavailable';
|
|
42
|
+
export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
|
|
43
43
|
export type SignInResult = {
|
|
44
44
|
ok: true;
|
|
45
45
|
redirectTo: string;
|
package/dist/auth.js
CHANGED
|
@@ -265,6 +265,16 @@ export function createAuth(input) {
|
|
|
265
265
|
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
266
266
|
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
267
267
|
}
|
|
268
|
+
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
269
|
+
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
270
|
+
}
|
|
271
|
+
// Both carry a sentence about the name, for the form to show as it is.
|
|
272
|
+
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
273
|
+
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
274
|
+
}
|
|
275
|
+
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
276
|
+
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
277
|
+
}
|
|
268
278
|
if (error instanceof BrokerError && error.error === 'request') {
|
|
269
279
|
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
270
280
|
}
|
|
@@ -390,6 +400,9 @@ function signInError(error) {
|
|
|
390
400
|
// A key the service will not take is the app's problem, not the visitor's.
|
|
391
401
|
if (error instanceof BrokerError && error.error === 'unauthorized')
|
|
392
402
|
return 'unavailable';
|
|
403
|
+
// The sign-in service's brute-force backstop tripped; ask them to wait.
|
|
404
|
+
if (error instanceof BrokerError && error.error === 'too_many')
|
|
405
|
+
return 'too_many';
|
|
393
406
|
if (error instanceof BrokerError && error.status < 500)
|
|
394
407
|
return 'invalid_credentials';
|
|
395
408
|
return 'unavailable';
|
package/dist/broker.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface NewUser {
|
|
|
9
9
|
password: string;
|
|
10
10
|
givenName: string;
|
|
11
11
|
familyName: string;
|
|
12
|
+
/**
|
|
13
|
+
* A login name they chose, where the app asks for one. Three to thirty of
|
|
14
|
+
* letters, digits, `.`, `_` and `-`; the service refuses reserved names
|
|
15
|
+
* and words it will not have. Without it the email is the login name.
|
|
16
|
+
*/
|
|
17
|
+
username?: string;
|
|
12
18
|
}
|
|
13
19
|
export declare class BrokerError extends Error {
|
|
14
20
|
readonly status: number;
|
package/dist/config.d.ts
CHANGED
|
@@ -5,8 +5,15 @@ export interface AuthOptions {
|
|
|
5
5
|
clientId: string | undefined;
|
|
6
6
|
/** From scripts/provisioned.json. Sent as a scope and checked on every token. */
|
|
7
7
|
organizationId: string | undefined;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app.
|
|
10
|
+
*
|
|
11
|
+
* Optional in the type as well as at runtime, because a caller that never
|
|
12
|
+
* seals a cookie should not have to mention it at all. Supplied and wrong is
|
|
13
|
+
* still a startup error; absent is only an error when something reaches for
|
|
14
|
+
* the key.
|
|
15
|
+
*/
|
|
16
|
+
cookieSecret?: string | undefined;
|
|
10
17
|
/** Default https://auth.wtfalch.dev. */
|
|
11
18
|
issuer?: string;
|
|
12
19
|
/** Where the routes are mounted. Default /auth. */
|
|
@@ -33,7 +40,19 @@ export interface ResolvedOptions {
|
|
|
33
40
|
appUrl: URL;
|
|
34
41
|
clientId: string;
|
|
35
42
|
organizationId: string;
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Decoded on first access, not at resolve time.
|
|
45
|
+
*
|
|
46
|
+
* Only `cookies.ts` touches it, to seal or open a session. An app that never
|
|
47
|
+
* does either has no use for the secret, and a CLI that sends an invitation
|
|
48
|
+
* is exactly that: it calls the broker, which needs `appKey` and `clientId`
|
|
49
|
+
* and nothing else. Requiring a cookie-signing key to send an email meant
|
|
50
|
+
* putting that secret in a second container to make one command run.
|
|
51
|
+
*
|
|
52
|
+
* A secret that was SUPPLIED is still decoded eagerly, so a typo is a startup
|
|
53
|
+
* error. Only the absence is deferred.
|
|
54
|
+
*/
|
|
55
|
+
readonly cookieKey: Uint8Array;
|
|
37
56
|
issuer: string;
|
|
38
57
|
basePath: string;
|
|
39
58
|
afterLogin: string;
|
package/dist/config.js
CHANGED
|
@@ -7,11 +7,28 @@ export function resolveOptions(options) {
|
|
|
7
7
|
if (!basePath.startsWith('/') || basePath.endsWith('/')) {
|
|
8
8
|
throw new Error(`@wtfalch/auth: basePath must start with "/" and not end with one, got "${basePath}"`);
|
|
9
9
|
}
|
|
10
|
+
/*
|
|
11
|
+
Checked NOW when one was supplied, and only then.
|
|
12
|
+
|
|
13
|
+
The two failures are different and deserve different timing. A secret that
|
|
14
|
+
is the wrong length is a typo, and a typo should be found when the app
|
|
15
|
+
starts rather than by the first person who tries to sign in. A secret that
|
|
16
|
+
is ABSENT may simply be an app that never seals a cookie -- a CLI sending an
|
|
17
|
+
invitation -- and refusing to start is how that ends up carrying a
|
|
18
|
+
cookie-signing key it never uses.
|
|
19
|
+
*/
|
|
20
|
+
if (options.cookieSecret !== undefined)
|
|
21
|
+
decodeKey(options.cookieSecret);
|
|
22
|
+
// Held here so the key is decoded at most once however often it is read.
|
|
23
|
+
let cookieKey;
|
|
10
24
|
return {
|
|
11
25
|
appUrl,
|
|
12
26
|
clientId: options.clientId,
|
|
13
27
|
organizationId: options.organizationId,
|
|
14
|
-
cookieKey
|
|
28
|
+
get cookieKey() {
|
|
29
|
+
cookieKey ??= decodeKey(options.cookieSecret);
|
|
30
|
+
return cookieKey;
|
|
31
|
+
},
|
|
15
32
|
issuer: (options.issuer ?? ISSUER).replace(/\/$/, ''),
|
|
16
33
|
basePath,
|
|
17
34
|
afterLogin: options.afterLogin ?? '/',
|
package/dist/redirect.js
CHANGED
|
@@ -12,5 +12,12 @@ export function safeNextPath(next, fallback) {
|
|
|
12
12
|
}
|
|
13
13
|
if (url.origin !== 'https://app.invalid')
|
|
14
14
|
return fallback;
|
|
15
|
-
|
|
15
|
+
const path = `${url.pathname}${url.search}`;
|
|
16
|
+
// The URL parser can normalise an input like "/..//evil" into a
|
|
17
|
+
// protocol-relative "//evil", which a caller resolving it against an origin
|
|
18
|
+
// would send to another host. Guard the OUTPUT, not just the input.
|
|
19
|
+
if (!path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\')) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
16
23
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Sign in against auth.wtfalch.dev from a Next.js app.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"directory": "packages/auth"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
|
-
"files": [
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
12
14
|
"exports": {
|
|
13
15
|
".": {
|
|
14
16
|
"types": "./dist/index.d.ts",
|
|
@@ -23,12 +25,6 @@
|
|
|
23
25
|
"engines": {
|
|
24
26
|
"node": ">=22.0.0"
|
|
25
27
|
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
28
|
-
"prepack": "pnpm build",
|
|
29
|
-
"typecheck": "tsc --noEmit",
|
|
30
|
-
"test": "vitest run"
|
|
31
|
-
},
|
|
32
28
|
"dependencies": {
|
|
33
29
|
"jose": "^6.2.10",
|
|
34
30
|
"openid-client": "6.8.7"
|
|
@@ -44,11 +40,16 @@
|
|
|
44
40
|
"devDependencies": {
|
|
45
41
|
"@types/node": "^22",
|
|
46
42
|
"@types/react": "^19",
|
|
47
|
-
"@wtfalch/auth-broker": "workspace:*",
|
|
48
43
|
"next": "^16",
|
|
49
44
|
"playwright-core": "^1.62.1",
|
|
50
45
|
"react": "^19",
|
|
51
46
|
"typescript": "^5.9.0",
|
|
52
|
-
"vitest": "^4.1.6"
|
|
47
|
+
"vitest": "^4.1.6",
|
|
48
|
+
"@wtfalch/auth-broker": "0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run"
|
|
53
54
|
}
|
|
54
|
-
}
|
|
55
|
+
}
|