@absolutejs/auth 0.54.9 → 0.55.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 +26 -5
- package/dist/agents/config.d.ts +16 -0
- package/dist/agents/idJag.d.ts +49 -0
- package/dist/agents/inMemoryStores.d.ts +2 -1
- package/dist/agents/index.d.ts +5 -2
- package/dist/agents/index.js +1427 -95
- package/dist/agents/index.js.map +12 -8
- package/dist/agents/postgresStores.d.ts +280 -1
- package/dist/agents/registration.d.ts +162 -0
- package/dist/agents/registrationClient.d.ts +50 -0
- package/dist/agents/routes.d.ts +144 -1
- package/dist/agents/types.d.ts +59 -0
- package/dist/apikeys/routes.d.ts +1 -1
- package/dist/authInstance.d.ts +12 -0
- package/dist/authorization/protectPermission.d.ts +2 -2
- package/dist/cli/migrate.js +57 -8
- package/dist/cli/migrate.js.map +4 -4
- package/dist/index.d.ts +14 -131
- package/dist/index.js +1535 -238
- package/dist/index.js.map +16 -13
- package/dist/manifest.js +12 -6
- package/dist/manifest.js.map +4 -4
- package/dist/manifest.json +2 -2
- package/dist/oidc/clientAuth.d.ts +6 -2
- package/dist/oidc/config.d.ts +19 -5
- package/dist/oidc/keys.d.ts +7 -3
- package/dist/oidc/logout.d.ts +2 -2
- package/dist/oidc/routes.d.ts +8 -6
- package/dist/routes/protectRoute.d.ts +2 -2
- package/dist/server.d.ts +18 -0
- package/dist/server.js +29362 -0
- package/dist/server.js.map +287 -0
- package/dist/utils.d.ts +1 -1
- package/docs/AGENT-AUTH.md +54 -0
- package/docs/MIGRATE-FROM-LUCIA.md +235 -0
- package/docs/OAUTH-PROVIDER-QUIRKS.md +150 -0
- package/docs/UI-COMPONENTS.md +226 -0
- package/package.json +14 -6
package/dist/utils.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare const resolveCookieSecure: (override?: boolean) => boolean;
|
|
|
21
21
|
export declare const resolveOAuthAuthorization: ({ authProvider, providerConfiguration, providerInstance, tokenResponse, now }: {
|
|
22
22
|
authProvider: string;
|
|
23
23
|
providerConfiguration?: ProviderConfig;
|
|
24
|
-
providerInstance: OAuth2Client<ProviderOption>;
|
|
24
|
+
providerInstance: Pick<OAuth2Client<ProviderOption>, "fetchUserProfile">;
|
|
25
25
|
tokenResponse: OAuth2TokenResponse;
|
|
26
26
|
now?: number;
|
|
27
27
|
}) => Promise<ResolvedOAuthAuthorization>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Agent authentication and registration
|
|
2
|
+
|
|
3
|
+
Absolute Auth implements agent registration inside `@absolutejs/auth`. There is
|
|
4
|
+
no separate `auth-md` package and no WorkOS service dependency. `/auth.md` is a
|
|
5
|
+
wire-compatible, generated companion document; OAuth metadata remains the
|
|
6
|
+
authoritative machine-readable contract.
|
|
7
|
+
|
|
8
|
+
## Interoperability contract
|
|
9
|
+
|
|
10
|
+
The implementation composes existing standards wherever they already define the
|
|
11
|
+
behavior:
|
|
12
|
+
|
|
13
|
+
- RFC 9728 protected-resource discovery
|
|
14
|
+
- RFC 8414 authorization-server discovery
|
|
15
|
+
- RFC 7523 JWT bearer grants
|
|
16
|
+
- RFC 8628-style user-code polling semantics
|
|
17
|
+
- RFC 9396-compatible authorization details in agent delegations
|
|
18
|
+
- the auth.md v0.6 registration projection and ID-JAG assertion type
|
|
19
|
+
|
|
20
|
+
The auth.md claim grant currently has a WorkOS-namespaced URN because that is the
|
|
21
|
+
identifier in the open v0.6 wire profile. It is isolated behind
|
|
22
|
+
`AGENT_CLAIM_GRANT_TYPE`; it does not indicate a WorkOS backend dependency. A
|
|
23
|
+
future neutral identifier can be accepted alongside it without changing the
|
|
24
|
+
internal store, verifier, or client contracts.
|
|
25
|
+
|
|
26
|
+
Protocol inputs are normalized into provider-neutral Absolute types. Identity
|
|
27
|
+
verification, user resolution, stores, key management, and token revocation are
|
|
28
|
+
injected interfaces. Provider adapters can therefore be added without changing
|
|
29
|
+
routes or application authorization logic.
|
|
30
|
+
|
|
31
|
+
## Security invariants
|
|
32
|
+
|
|
33
|
+
- The service owns the account-linking page. Agents never receive passwords,
|
|
34
|
+
MFA codes, or service session cookies.
|
|
35
|
+
- ID-JAG assertions are signature-, issuer-, audience-, client-, freshness-,
|
|
36
|
+
verification-, and replay-checked before identity resolution.
|
|
37
|
+
- A durable identity key includes issuer, subject, and client id. Different
|
|
38
|
+
agents acting for the same user cannot collapse into one registration.
|
|
39
|
+
- Claim and attempt tokens are stored only as hashes. User codes have bounded
|
|
40
|
+
attempts, short expirations, constant-time comparison, and atomic updates.
|
|
41
|
+
- Polling is rate-enforced and returns `slow_down` when the advertised interval
|
|
42
|
+
is ignored.
|
|
43
|
+
- Access tokens are opaque, scoped, short-lived, and looked up in a revocable
|
|
44
|
+
store. Anonymous pre-claim credentials must be revoked before ownership is
|
|
45
|
+
committed.
|
|
46
|
+
- Structured discovery never advertises an optional capability, such as
|
|
47
|
+
security-event delivery, unless the deployment actually implements it.
|
|
48
|
+
|
|
49
|
+
## Deployment checklist
|
|
50
|
+
|
|
51
|
+
Use the Postgres identity-registration store in production, run the `agents`
|
|
52
|
+
migrations, keep signing keys in managed key storage, make anonymous-token
|
|
53
|
+
revocation idempotent, pin trusted assertion issuers and client ids, and apply
|
|
54
|
+
normal edge rate limits to identity, claim, completion, and token routes.
|
|
@@ -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.
|