@absolutejs/auth 0.54.9 → 0.55.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.
Files changed (44) hide show
  1. package/README.md +19 -5
  2. package/dist/agents/config.d.ts +16 -0
  3. package/dist/agents/idJag.d.ts +49 -0
  4. package/dist/agents/inMemoryStores.d.ts +2 -1
  5. package/dist/agents/index.d.ts +5 -2
  6. package/dist/agents/index.js +1391 -66
  7. package/dist/agents/index.js.map +12 -8
  8. package/dist/agents/postgresStores.d.ts +280 -1
  9. package/dist/agents/registration.d.ts +162 -0
  10. package/dist/agents/registrationClient.d.ts +50 -0
  11. package/dist/agents/routes.d.ts +112 -1
  12. package/dist/agents/types.d.ts +59 -0
  13. package/dist/apikeys/routes.d.ts +2 -2
  14. package/dist/cli/migrate.js +57 -8
  15. package/dist/cli/migrate.js.map +4 -4
  16. package/dist/credentials/login.d.ts +4 -4
  17. package/dist/credentials/routes.d.ts +4 -4
  18. package/dist/index.d.ts +185 -2
  19. package/dist/index.js +1388 -102
  20. package/dist/index.js.map +15 -12
  21. package/dist/manifest.js +12 -6
  22. package/dist/manifest.js.map +4 -4
  23. package/dist/manifest.json +2 -2
  24. package/dist/mfa/routes.d.ts +2 -2
  25. package/dist/mfa/sms.d.ts +2 -2
  26. package/dist/oidc/clientAuth.d.ts +6 -2
  27. package/dist/oidc/config.d.ts +19 -5
  28. package/dist/oidc/keys.d.ts +7 -3
  29. package/dist/oidc/logout.d.ts +2 -2
  30. package/dist/oidc/routes.d.ts +13 -11
  31. package/dist/organizations/routes.d.ts +1 -1
  32. package/dist/portal/routes.d.ts +3 -3
  33. package/dist/roles/routes.d.ts +1 -1
  34. package/dist/routes/refresh.d.ts +1 -1
  35. package/dist/routes/revoke.d.ts +1 -1
  36. package/dist/routes/sessions.d.ts +3 -3
  37. package/dist/sso/discoveryRoute.d.ts +1 -1
  38. package/dist/sso/oidcRoutes.d.ts +2 -2
  39. package/dist/sso/samlRoutes.d.ts +4 -4
  40. package/docs/AGENT-AUTH.md +54 -0
  41. package/docs/MIGRATE-FROM-LUCIA.md +235 -0
  42. package/docs/OAUTH-PROVIDER-QUIRKS.md +150 -0
  43. package/docs/UI-COMPONENTS.md +226 -0
  44. package/package.json +6 -3
@@ -0,0 +1,235 @@
1
+ # Migrating from Lucia (v3) to `@absolutejs/auth`
2
+
3
+ Lucia was deprecated in 2024. The maintainer's note —
4
+ *"database adapters have been a significant complexity tax"* — is exactly
5
+ the problem `@absolutejs/auth` solves by **owning the schema** (via
6
+ Drizzle migrations + the `bunx absolute-auth migrate` CLI) instead of
7
+ abstracting around yours.
8
+
9
+ If you're on Lucia v3, this guide walks you through the migration
10
+ end-to-end. It assumes Postgres + Drizzle ORM; SQLite + better-sqlite3
11
+ follow the same flow with the obvious column-type swaps.
12
+
13
+ > Status: tested against Lucia v3.2.x. Open an issue with your Lucia
14
+ > config snippet if you hit a shape this guide doesn't cover.
15
+
16
+ ## What you keep
17
+
18
+ - **Your user table** — `@absolutejs/auth` doesn't rename `users.id`,
19
+ drop fields, or require you to give up your domain columns. The
20
+ migration adds `users.sub` (UUID) alongside whatever you already have.
21
+ - **Session semantics** — server-side sessions with an httpOnly cookie,
22
+ same trust model as Lucia. We use Redis or in-memory; the cookie name
23
+ is `user_session_id` (configurable).
24
+ - **Argon2id password hashes** — Lucia's default. We accept them
25
+ unchanged (`@absolutejs/auth` uses `Bun.password.hash` which also
26
+ defaults to argon2id).
27
+
28
+ ## What changes
29
+
30
+ | Lucia | `@absolutejs/auth` |
31
+ |---|---|
32
+ | `lucia.createSession(userId, attributes)` | Implicit — `auth<UserType>()` creates the session on a successful OAuth callback or `signin.email({...})`. |
33
+ | `lucia.validateSession(sessionId)` | Use the `protectRoutePlugin` decorator on routes that need an authenticated user. |
34
+ | `lucia.invalidateSession(sessionId)` | `await authSessionStore.removeSession(sessionId)` or hit `/oauth2/signout`. |
35
+ | `lucia.invalidateUserSessions(userId)` | `await authSessionStore.removeUserSessions(userId)`. |
36
+ | `getUserAttributes` hook | Move logic into your route handler; the user is already in `context.user`. |
37
+ | Custom `Adapter` (SQLite / Postgres / D1) | Not needed — schema is fixed via Drizzle migrations. |
38
+ | `Lucia.Register` module-augmentation trick | Plain generic — `auth<MyUser>({...})`. |
39
+
40
+ ## Step-by-step
41
+
42
+ ### 1. Install + bootstrap
43
+
44
+ ```bash
45
+ bun add @absolutejs/auth elysia citra
46
+ bunx absolute-auth migrate
47
+ ```
48
+
49
+ The CLI generates the Drizzle migrations for `users`, `auth_identities`,
50
+ `auth_sessions`, `auth_login_history` (adaptive risk), and so on. Run
51
+ them against your DB.
52
+
53
+ ### 2. Backfill from Lucia's tables
54
+
55
+ The package ships an import CLI that handles this end-to-end:
56
+
57
+ ```bash
58
+ # Export your Lucia tables to a single JSON file:
59
+ psql $LUCIA_DB -c "\COPY (SELECT json_agg(row_to_json(u)) FROM \"user\" u) TO 'users.json'"
60
+ psql $LUCIA_DB -c "\COPY (SELECT json_agg(row_to_json(k)) FROM \"key\" k) TO 'keys.json'"
61
+ # Concatenate into one document:
62
+ jq -s '{ users: .[0], keys: .[1] }' users.json keys.json > lucia-export.json
63
+
64
+ # Dry-run (counts only, no inserts):
65
+ bunx absolute-auth import lucia lucia-export.json --db $DATABASE_URL
66
+
67
+ # Commit:
68
+ bunx absolute-auth import lucia lucia-export.json --db $DATABASE_URL --commit
69
+ ```
70
+
71
+ The CLI extracts `email:`/`username:` keys → `users.password` and
72
+ treats the rest as OAuth identities → `auth_identities`. Re-running is
73
+ idempotent (`ON CONFLICT DO NOTHING` on email + provider:subject).
74
+
75
+ If you'd rather do the SQL yourself (e.g. you need custom column
76
+ mapping):
77
+
78
+ ```ts
79
+ // scripts/migrateFromLucia.ts
80
+ import { neon } from '@neondatabase/serverless';
81
+
82
+ const sql = neon(process.env.DATABASE_URL);
83
+
84
+ // 1. Users — Lucia's `user` → @absolutejs/auth's `users`
85
+ // Generate fresh UUIDs for `sub`; keep your existing primary key
86
+ // aliased in `legacy_id` for downstream-FK rewrites if needed.
87
+ await sql`
88
+ INSERT INTO users (sub, email, created_at, /* your fields */)
89
+ SELECT
90
+ gen_random_uuid()::text,
91
+ email,
92
+ COALESCE(created_at, NOW()),
93
+ /* your fields */
94
+ FROM lucia_user
95
+ ON CONFLICT (email) DO NOTHING
96
+ `;
97
+
98
+ // 2. OAuth identities — Lucia's `key` rows with `:` prefix
99
+ // (e.g. `google:1234567890`) → auth_identities
100
+ await sql`
101
+ INSERT INTO auth_identities (id, auth_provider, provider_subject, user_sub)
102
+ SELECT
103
+ split_part(k.id, ':', 1) || ':' || split_part(k.id, ':', 2),
104
+ split_part(k.id, ':', 1),
105
+ split_part(k.id, ':', 2),
106
+ u.sub
107
+ FROM lucia_key k
108
+ JOIN lucia_user l ON l.id = k.user_id
109
+ JOIN users u ON u.email = l.email
110
+ WHERE k.id LIKE '%:%'
111
+ ON CONFLICT DO NOTHING
112
+ `;
113
+
114
+ // 3. Password hashes — Lucia's `key` with `hashed_password`
115
+ // Argon2id format carries over unchanged.
116
+ await sql`
117
+ UPDATE users SET password = k.hashed_password
118
+ FROM lucia_key k
119
+ JOIN lucia_user l ON l.id = k.user_id
120
+ WHERE k.hashed_password IS NOT NULL
121
+ AND users.email = l.email
122
+ `;
123
+
124
+ // 4. Sessions — DON'T migrate. Lucia uses opaque IDs; we use a different
125
+ // cookie name + scheme. Force a re-login on cutover by NOT copying
126
+ // the session table.
127
+ ```
128
+
129
+ ### 3. Wire the new auth config
130
+
131
+ ```ts
132
+ import { Elysia } from 'elysia';
133
+ import { auth, createNeonAuthSessionStore } from '@absolutejs/auth';
134
+
135
+ type MyUser = {
136
+ email: string;
137
+ sub: string;
138
+ // …whatever your domain user has
139
+ };
140
+
141
+ const authSessionStore = createNeonAuthSessionStore<MyUser>();
142
+
143
+ const app = await auth<MyUser>({
144
+ authSessionStore,
145
+ // your existing OAuth provider config (Google, GitHub, …)
146
+ providersConfiguration: { google: { /* … */ } },
147
+ // The bridge from a decoded token → your user shape.
148
+ // Lucia's `getUserAttributes` lives here now.
149
+ getUser: (decoded) => ({
150
+ email: decoded.email,
151
+ sub: /* … */,
152
+ }),
153
+ });
154
+ ```
155
+
156
+ ### 4. Replace `validateSession` call sites
157
+
158
+ Before (Lucia):
159
+
160
+ ```ts
161
+ app.get('/me', async (ctx) => {
162
+ const { session, user } = await lucia.validateSession(
163
+ ctx.cookies.get('auth_session') ?? ''
164
+ );
165
+ if (!session) return ctx.error(401);
166
+
167
+ return user;
168
+ });
169
+ ```
170
+
171
+ After (`@absolutejs/auth`):
172
+
173
+ ```ts
174
+ import { protectRoutePlugin } from '@absolutejs/auth';
175
+
176
+ app
177
+ .use(protectRoutePlugin<MyUser>())
178
+ .get('/me', ({ user }) => user); // typed as MyUser, throws 401 if unauthenticated
179
+ ```
180
+
181
+ ### 5. Switch the cookie name (optional)
182
+
183
+ Lucia's default cookie is `auth_session`. `@absolutejs/auth` uses
184
+ `user_session_id`. If you want zero re-logins after the cutover (you
185
+ also can't — see Step 2 — but if you've decided to migrate sessions
186
+ manually), the session cookie config is overridable. We recommend
187
+ NOT migrating sessions; the cutover is the right moment to force
188
+ re-auth.
189
+
190
+ ### 6. Drop the Lucia tables
191
+
192
+ ```sql
193
+ DROP TABLE lucia_session, lucia_key, lucia_user;
194
+ ```
195
+
196
+ (After the migration script verifies zero new user creation in Lucia.)
197
+
198
+ ## What you get post-migration that you didn't have before
199
+
200
+ - **OIDC provider role** — your app can issue OAuth tokens to other apps
201
+ via `/.well-known/openid-configuration`. Lucia is an RP only.
202
+ - **MFA (TOTP) + passkeys (WebAuthn) + magic links** — all first-party,
203
+ Lucia required third-party libraries.
204
+ - **SCIM 2.0 + SAML 2.0 IdP role** for enterprise SSO.
205
+ - **Tamper-evident audit log** with hash-chained events + SIEM streaming.
206
+ - **Adaptive risk** + strong device fingerprinting on every login.
207
+ - **Drop-in OAuth provider library** ([citra](https://www.npmjs.com/package/citra))
208
+ — every quirk Slack/LinkedIn/QuickBooks/Salesforce inflict on you,
209
+ centrally handled. See [OAUTH-PROVIDER-QUIRKS.md](./OAUTH-PROVIDER-QUIRKS.md).
210
+
211
+ ## Common questions
212
+
213
+ **Q: My Lucia setup used SQLite. Does this work?**
214
+ Yes — Drizzle supports SQLite, the migration SQL above swaps to the
215
+ SQLite dialect. The argon2id verifiers, OAuth flows, and TS types are
216
+ all SQL-dialect-agnostic.
217
+
218
+ **Q: I have custom `DatabaseUserAttributes` from Lucia's module
219
+ augmentation. How do I port that?**
220
+ That's just your `UserType` now. Pass it as the generic to `auth<T>()`
221
+ — compile-time enforced everywhere downstream, no module augmentation.
222
+
223
+ **Q: I used Lucia's "key" abstraction for non-OAuth identities (Apple
224
+ Sign In via custom claims, magic-link tokens, etc.). Where do those go?**
225
+ Magic links: built-in via the `passwordless` block. Apple Sign In:
226
+ treated as a standard OAuth provider via citra. Any other custom
227
+ "identity" you owned: write a row into `auth_identities` with your
228
+ chosen `auth_provider` value (e.g. `apple`).
229
+
230
+ **Q: I want to keep Lucia's sessions live during the cutover.**
231
+ The cleanest path is to NOT — force a re-login. If you must, write a
232
+ session-bridge middleware that reads Lucia's `auth_session` cookie,
233
+ calls Lucia's `validateSession`, mints a new `@absolutejs/auth` session
234
+ in our store, and sets the new cookie. Single-PR rollback path:
235
+ remove the bridge, Lucia's cookie expires naturally.
@@ -0,0 +1,150 @@
1
+ # OAuth Provider Quirks Reference
2
+
3
+ Nango's 2026 post — *"the real-world OAuth experience is comparable to
4
+ JavaScript browser APIs in 2008"* — is accurate. Every major OAuth
5
+ provider implements the spec a little differently, and the gap between
6
+ "works on Localhost with Google" and "works in production with eight
7
+ providers" is where most auth libraries lose their developers.
8
+
9
+ `@absolutejs/auth` delegates provider-specific knowledge to
10
+ [citra](https://www.npmjs.com/package/citra), which means the quirks
11
+ below are handled for you. This doc exists so you know *what* citra is
12
+ handling — useful when debugging, when a provider rolls out a breaking
13
+ change, or when you're picking which providers to support.
14
+
15
+ ## Quick reference
16
+
17
+ | Provider | Standard? | Notable quirks |
18
+ |---|---|---|
19
+ | Google | ✓ Mostly | Email scope `openid email profile`; refresh tokens require `access_type=offline&prompt=consent`. |
20
+ | GitHub | ✗ Not OIDC | Returns `access_token` directly (no `id_token`); the user email needs a second call to `/user/emails` because the primary OAuth payload omits it for users who hide their email. |
21
+ | LinkedIn | ✗ | **Silently fails with PKCE.** citra disables PKCE for LinkedIn — passing it returns a confusing "Unauthorized" error from LinkedIn's `/oauth/v2/accessToken` with no body. |
22
+ | Slack | ✗ | **Two scope types** — `scope` (workspace-installed bot scopes) and `user_scope` (Sign in with Slack). citra exposes both separately. |
23
+ | Discord | ✓ Mostly | Email scope returns a different payload shape on `/users/@me` vs the OIDC `userinfo` endpoint; citra normalizes. |
24
+ | Facebook | ✗ | Token exchange uses GET, not POST. App tokens have a different format from user tokens. citra picks the right one. |
25
+ | Microsoft / Entra | ✓ | `tid` (tenant) claim is the multi-tenant routing key. Personal accounts vs work accounts return different `iss` claims; citra exposes both. |
26
+ | Apple | ✗ | **`form_post` response mode by default** — the redirect comes back as a POST, not GET. citra registers a POST handler. ID token returns first-name/last-name on FIRST sign-in only; we cache it. |
27
+ | QuickBooks | ✗ | Adds a `realmId` query param to the redirect — not in the spec, but essential (it's the QuickBooks company-file scope). citra exposes it as a top-level property. |
28
+ | Salesforce | ✗ | Returns `instance_url` (which sandbox/region) outside the spec. citra exposes it. Refresh-token TTL is governed by org-level setting "Refresh Token Policy", not OAuth defaults. |
29
+ | Notion | ✗ | The `bot_id` in the token response is the workspace integration — not the user. citra makes the distinction. |
30
+ | Zoom | ✓ Mostly | Refresh tokens rotate on every use AND invalidate other refresh tokens for the same user — single-device limit by default. |
31
+ | Atlassian (Jira / Confluence) | ✗ | Per-resource access tokens. citra fetches `/oauth/token/accessible-resources` after token exchange to expose the cloud IDs the token can access. |
32
+ | Spotify | ✓ | Standard. |
33
+ | Twitch | ✓ Mostly | The OAuth flow uses `id_token` but the `at_hash` calculation diverges from the spec — citra skips at_hash verification for Twitch. |
34
+ | Yahoo | ✗ | Uses non-standard `userinfo` paths per region (US vs JP). citra handles per-region. |
35
+ | Stack Overflow | ✗ | Token endpoint returns the access token URL-encoded in the body (e.g. `access_token=abc&expires=86400`), not JSON. citra parses it. |
36
+ | Dropbox | ✗ | Refresh tokens not issued unless you pass `token_access_type=offline` — undocumented. citra adds it. |
37
+ | Reddit | ✗ | Returns `error: 'unsupported_response_type'` if you DON'T also pass `duration=permanent` for refresh-capable flows. citra adds it. |
38
+ | X (Twitter) v2 | ✓ Mostly | Required `code_challenge_method=plain` if you pass `state` containing a `+`. citra escapes. |
39
+
40
+ ## The hairy ones in detail
41
+
42
+ ### LinkedIn
43
+
44
+ **Problem:** LinkedIn's IdP rejects PKCE proofs without a useful error.
45
+ The `/oauth/v2/accessToken` call returns 401 Unauthorized with an HTML
46
+ body (yes, HTML) explaining nothing.
47
+
48
+ **citra's handling:** LinkedIn's provider config sets `pkce: false`.
49
+ The redirect URL doesn't include `code_challenge`; the token exchange
50
+ doesn't include `code_verifier`. State + nonce only.
51
+
52
+ **Implication for security:** LinkedIn is a public OAuth client (no
53
+ client-bound proof of possession). Combine with strict `redirect_uri`
54
+ matching, short auth-code TTLs, and prefer `private_key_jwt` if you
55
+ have an enterprise LinkedIn instance.
56
+
57
+ ### Apple Sign In
58
+
59
+ **Problem 1:** Apple POSTs the callback (form-post response mode) by
60
+ default. Most Express/Elysia setups only register the redirect handler
61
+ on `GET /callback`.
62
+
63
+ **citra's handling:** the redirect handler accepts both GET and POST
64
+ methods. The package's `callback.ts` extracts the auth code from either
65
+ query or form body.
66
+
67
+ **Problem 2:** Apple includes the user's first/last name as a JSON
68
+ payload in the `user` parameter on the **FIRST** sign-in only. Re-logins
69
+ omit it. If you wait until the second sign-in to extract the name,
70
+ you'll never see it.
71
+
72
+ **citra's handling:** extracts on first call + persists. Subsequent
73
+ sign-ins return the cached name from `auth_identities.metadata`.
74
+
75
+ ### Slack
76
+
77
+ **Problem:** Slack has two distinct OAuth scope vocabularies — `scope`
78
+ (bot/installer scopes) and `user_scope` (Sign in with Slack). Passing
79
+ only `scope` works for app install but fails for Sign in with Slack;
80
+ passing only `user_scope` works for Sign in with Slack but doesn't
81
+ install the app.
82
+
83
+ **citra's handling:** the Slack provider config exposes both as
84
+ separate fields.
85
+
86
+ ### QuickBooks
87
+
88
+ **Problem:** QuickBooks adds a non-spec `realmId` parameter to the
89
+ redirect — it identifies which company file the user authorized. If
90
+ you don't capture it, you've lost the only way to know which QuickBooks
91
+ account this token operates on.
92
+
93
+ **citra's handling:** parses `realmId` from the callback URL and
94
+ exposes it on the decoded payload alongside `email` / `sub` / etc.
95
+
96
+ ### Salesforce
97
+
98
+ **Problem 1:** The token response includes `instance_url` — the URL of
99
+ the Salesforce org — which is where all subsequent API calls must go.
100
+ The OAuth spec has no field for this, so most libraries drop it.
101
+
102
+ **citra's handling:** `instance_url` exposed on the decoded payload.
103
+
104
+ **Problem 2:** Refresh token TTL is governed by the Salesforce org's
105
+ "Refresh Token Policy" setting, which can be configured to expire
106
+ sooner than standard OAuth defaults. If you assume standard semantics,
107
+ you'll get random 401s when the org admin tightens the policy.
108
+
109
+ **citra's handling:** treats every refresh as potentially failing and
110
+ returns an explicit error code; the consumer's `OnRefreshError` handler
111
+ gets the chance to redirect the user back through the authorize flow.
112
+
113
+ ### Microsoft / Entra ID
114
+
115
+ **Problem:** Personal accounts and work accounts return different
116
+ `iss` (issuer) claims:
117
+
118
+ ```
119
+ work: https://login.microsoftonline.com/{tenant-id}/v2.0
120
+ personal: https://login.microsoftonline.com/9188040d-...-bf63a3.../v2.0
121
+ ```
122
+
123
+ A naive `iss` allowlist that only includes one breaks the other.
124
+
125
+ **citra's handling:** issuer-check matches by prefix
126
+ `https://login.microsoftonline.com/`. JWKS lookup goes via the `iss`
127
+ claim's tenant.
128
+
129
+ ## When citra needs to evolve
130
+
131
+ Providers change their OAuth behavior unilaterally. When you see:
132
+ - A new error code in an `OnCallbackError` log,
133
+ - A field you used to get from the decoded payload going missing,
134
+ - A `401` from a refresh that used to work,
135
+
136
+ …the fastest fix is usually to bump citra to its latest version
137
+ (`bun add citra@latest`). citra ships a per-provider integration test
138
+ suite + tracks upstream changes; typical lag between a provider
139
+ breaking change and a citra patch is days, not months.
140
+
141
+ If a fix isn't in citra yet, open an issue at
142
+ [github.com/absolutejs/citra](https://github.com/absolutejs/citra) with
143
+ the provider name + the specific request/response that failed. The
144
+ provider config is one TypeScript file per provider; patches are usually
145
+ 5–30 lines.
146
+
147
+ ## Recommended reading
148
+
149
+ - [Nango: Why is OAuth Still Hard in 2026?](https://nango.dev/blog/why-is-oauth-still-hard/)
150
+ - [RFC 9700 (OAuth 2.0 Best Current Practice)](https://datatracker.ietf.org/doc/rfc9700/) — the FAPI 2.0 baseline; pair with `strictFapi: true`.
@@ -0,0 +1,226 @@
1
+ # Drop-in UI Components
2
+
3
+ The most common reason developers stay on Clerk / Auth0 isn't the auth
4
+ itself — it's the polished `<UserButton />` and `<SignIn />` components
5
+ that go with them. This page is the answer to *"but I'd lose Clerk's
6
+ `<UserButton />`."*
7
+
8
+ `@absolutejs/auth` ships three headless components (React) + the recipe
9
+ to build the same in Vue / Svelte / Solid using the existing
10
+ composables. Every component is restyleable via a `classNames` prop;
11
+ every internal element carries a `data-abs-auth="…"` attribute the
12
+ consumer can target from CSS without touching the source.
13
+
14
+ ## React
15
+
16
+ The components are real React functional components in
17
+ `@absolutejs/auth/react`:
18
+
19
+ ```tsx
20
+ import { authClient } from './shared/authClient'; // your createAuthClient instance
21
+ import { SignIn, SignUp, UserButton } from '@absolutejs/auth/react';
22
+
23
+ // Sign-in page
24
+ <SignIn
25
+ client={authClient}
26
+ providers={['google', 'github']} // optional OAuth buttons above the form
27
+ onSuccess={(result) => {
28
+ if (result.status === 'mfa_required') router.push('/mfa');
29
+ else router.push('/dashboard');
30
+ }}
31
+ onError={(error) => console.error(error.message)}
32
+ classNames={{
33
+ container: 'flex flex-col gap-4 max-w-sm',
34
+ button: 'rounded-md bg-violet-600 text-white py-2',
35
+ input: 'border-slate-200 border rounded px-3 py-2',
36
+ // …all classNames are optional
37
+ }}
38
+ />
39
+
40
+ // Sign-up page (always email/password; OAuth registration uses /authorize directly)
41
+ <SignUp
42
+ client={authClient}
43
+ onSuccess={(result) => {
44
+ if (result.status === 'verification_required') router.push('/verify-email');
45
+ else router.push('/dashboard');
46
+ }}
47
+ />
48
+
49
+ // Top navigation user button
50
+ <UserButton
51
+ client={authClient}
52
+ user={user} // your AuthUser-shaped record (you keep the session state)
53
+ items={[
54
+ { label: 'Settings', href: '/settings' },
55
+ { label: 'API keys', href: '/settings/api' },
56
+ ]}
57
+ onSignOut={() => router.push('/')}
58
+ />
59
+ ```
60
+
61
+ ### `data-abs-auth` attributes — restyle without classNames
62
+
63
+ If you don't want to thread classNames through every render call, set
64
+ your CSS based on the data attribute selector:
65
+
66
+ ```css
67
+ [data-abs-auth='sign-in'] { display: flex; flex-direction: column; gap: 1rem; }
68
+ [data-abs-auth='submit']:disabled { opacity: 0.5; cursor: not-allowed; }
69
+ [data-abs-auth='oauth-grid'] { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
70
+ [data-abs-auth='menu'] { position: absolute; right: 0; top: 100%; }
71
+ ```
72
+
73
+ ## Vue 3 — copy-paste recipes
74
+
75
+ Vue SFCs have their own compile step that's outside this package's
76
+ build scope, so we ship the composables (`useSignIn`, `useSignUp`,
77
+ `useSignOut`) and you drop a ~30-line SFC into your project:
78
+
79
+ ```vue
80
+ <!-- src/components/auth/SignIn.vue -->
81
+ <script setup lang="ts">
82
+ import { ref } from 'vue';
83
+ import { useSignIn } from '@absolutejs/auth/vue';
84
+ import type { AuthClient } from '@absolutejs/auth/client';
85
+
86
+ const props = defineProps<{ client: AuthClient }>();
87
+ const emit = defineEmits<{ success: [unknown]; error: [Error] }>();
88
+
89
+ const email = ref('');
90
+ const password = ref('');
91
+ const { error, isPending, mutate } = useSignIn(props.client);
92
+
93
+ const onSubmit = async () => {
94
+ const result = await mutate({ email: email.value, password: password.value });
95
+ if (result.error !== null) emit('error', result.error);
96
+ else if (result.data !== null) emit('success', result.data);
97
+ };
98
+ </script>
99
+
100
+ <template>
101
+ <form @submit.prevent="onSubmit" data-abs-auth="sign-in">
102
+ <label data-abs-auth="email-field">
103
+ <span>Email</span>
104
+ <input v-model="email" type="email" autocomplete="username webauthn" required />
105
+ </label>
106
+ <label data-abs-auth="password-field">
107
+ <span>Password</span>
108
+ <input v-model="password" type="password" autocomplete="current-password" minlength="12" required />
109
+ </label>
110
+ <p v-if="$error" role="alert" data-abs-auth="error">{{ $error.message }}</p>
111
+ <button type="submit" :disabled="isPending" data-abs-auth="submit">
112
+ {{ isPending ? 'Signing in…' : 'Sign in' }}
113
+ </button>
114
+ </form>
115
+ </template>
116
+ ```
117
+
118
+ `SignUp.vue` and `UserButton.vue` follow the same shape — swap
119
+ `useSignIn` for `useSignUp` / `useSignOut`.
120
+
121
+ ## Svelte 5 (runes) — copy-paste recipe
122
+
123
+ ```svelte
124
+ <!-- src/lib/components/SignIn.svelte -->
125
+ <script lang="ts">
126
+ import { useSignIn } from '@absolutejs/auth/svelte';
127
+ import type { AuthClient } from '@absolutejs/auth/client';
128
+
129
+ let { client, onSuccess, onError }: {
130
+ client: AuthClient;
131
+ onSuccess?: (result: unknown) => void;
132
+ onError?: (error: Error) => void;
133
+ } = $props();
134
+
135
+ let email = $state('');
136
+ let password = $state('');
137
+ const { error, isPending, mutate } = useSignIn(client);
138
+
139
+ const onSubmit = async (event: SubmitEvent) => {
140
+ event.preventDefault();
141
+ const result = await mutate({ email, password });
142
+ if (result.error !== null) onError?.(result.error);
143
+ else if (result.data !== null) onSuccess?.(result.data);
144
+ };
145
+ </script>
146
+
147
+ <form onsubmit={onSubmit} data-abs-auth="sign-in">
148
+ <label data-abs-auth="email-field">
149
+ <span>Email</span>
150
+ <input bind:value={email} type="email" autocomplete="username webauthn" required />
151
+ </label>
152
+ <label data-abs-auth="password-field">
153
+ <span>Password</span>
154
+ <input bind:value={password} type="password" autocomplete="current-password" minlength="12" required />
155
+ </label>
156
+ {#if $error}
157
+ <p role="alert" data-abs-auth="error">{$error.message}</p>
158
+ {/if}
159
+ <button type="submit" disabled={$isPending} data-abs-auth="submit">
160
+ {$isPending ? 'Signing in…' : 'Sign in'}
161
+ </button>
162
+ </form>
163
+ ```
164
+
165
+ ## Solid — copy-paste recipe
166
+
167
+ ```tsx
168
+ // src/components/auth/SignIn.tsx
169
+ import { createSignal, type Component } from 'solid-js';
170
+ import { useSignIn } from '@absolutejs/auth/solid';
171
+ import type { AuthClient } from '@absolutejs/auth/client';
172
+
173
+ export const SignIn: Component<{
174
+ client: AuthClient;
175
+ onSuccess?: (result: unknown) => void;
176
+ onError?: (error: Error) => void;
177
+ }> = (props) => {
178
+ const [email, setEmail] = createSignal('');
179
+ const [password, setPassword] = createSignal('');
180
+ const { error, isPending, mutate } = useSignIn(props.client);
181
+
182
+ const onSubmit = async (event: SubmitEvent) => {
183
+ event.preventDefault();
184
+ const result = await mutate({ email: email(), password: password() });
185
+ if (result.error !== null) props.onError?.(result.error);
186
+ else if (result.data !== null) props.onSuccess?.(result.data);
187
+ };
188
+
189
+ return (
190
+ <form onSubmit={onSubmit} data-abs-auth="sign-in">
191
+ <label data-abs-auth="email-field">
192
+ <span>Email</span>
193
+ <input value={email()} onInput={(e) => setEmail(e.currentTarget.value)}
194
+ type="email" autocomplete="username webauthn" required />
195
+ </label>
196
+ <label data-abs-auth="password-field">
197
+ <span>Password</span>
198
+ <input value={password()} onInput={(e) => setPassword(e.currentTarget.value)}
199
+ type="password" autocomplete="current-password" minlength={12} required />
200
+ </label>
201
+ {error() && (
202
+ <p role="alert" data-abs-auth="error">{error()!.message}</p>
203
+ )}
204
+ <button type="submit" disabled={isPending()} data-abs-auth="submit">
205
+ {isPending() ? 'Signing in…' : 'Sign in'}
206
+ </button>
207
+ </form>
208
+ );
209
+ };
210
+ ```
211
+
212
+ ## Why we don't ship Vue/Svelte/Solid SFCs in the package
213
+
214
+ Each non-React framework needs its own SFC compiler bundled into the
215
+ build pipeline (`@vitejs/plugin-vue`, `@sveltejs/vite-plugin-svelte`,
216
+ `solid-vite`). Adopting four parallel build paths in a single package
217
+ inflates the install footprint by 30–60 MB and creates a maintenance
218
+ matrix that the React-only approach doesn't have.
219
+
220
+ The composables (`useSignIn`, `useSignUp`, etc.) are framework-native
221
+ and shipped in `@absolutejs/auth/{vue,svelte,solid}`. Wiring a
222
+ 20-30-line SFC over them is faster than configuring our build to ship
223
+ them precompiled — and gives you full control of the markup.
224
+
225
+ If you'd like SFC-native components added to the package despite the
226
+ build cost, open an issue.
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.54.9",
2
+ "version": "0.55.0",
3
3
  "name": "@absolutejs/auth",
4
4
  "description": "An authorization library for absolutejs",
5
5
  "repository": {
@@ -30,6 +30,8 @@
30
30
  "authorization",
31
31
  "authentication",
32
32
  "absolutejs",
33
+ "agent-auth",
34
+ "auth.md",
33
35
  "elysia",
34
36
  "oauth"
35
37
  ],
@@ -73,10 +75,10 @@
73
75
  },
74
76
  "dependencies": {
75
77
  "@absolutejs/linked-providers": "0.0.2",
78
+ "@absolutejs/manifest": "^0.3.0",
76
79
  "@neondatabase/serverless": "1.0.0",
77
- "citra": "0.29.7",
78
- "@absolutejs/manifest": "^0.2.0",
79
80
  "@sinclair/typebox": "^0.34.0",
81
+ "citra": "0.29.7",
80
82
  "drizzle-orm": "1.0.0-rc.3"
81
83
  },
82
84
  "devDependencies": {
@@ -182,6 +184,7 @@
182
184
  "type": "module",
183
185
  "files": [
184
186
  "dist",
187
+ "docs",
185
188
  "README.md"
186
189
  ],
187
190
  "typesVersions": {