@wtfalch/auth 0.1.0 → 0.1.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 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/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
- /** 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app. */
9
- cookieSecret: string | undefined;
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
- cookieKey: Uint8Array;
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: decodeKey(options.cookieSecret),
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Sign in against auth.wtfalch.dev from a Next.js app.",
5
5
  "repository": {
6
6
  "type": "git",