@everystack/auth 0.1.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.
- package/README.md +256 -0
- package/package.json +104 -0
- package/src/api.ts +65 -0
- package/src/cff/index.ts +157 -0
- package/src/client/index.tsx +139 -0
- package/src/context.ts +30 -0
- package/src/cookies.ts +93 -0
- package/src/index.ts +526 -0
- package/src/jwt/hmac-node.ts +11 -0
- package/src/jwt/hmac-web.ts +17 -0
- package/src/jwt/hmac.ts +10 -0
- package/src/jwt/index.ts +182 -0
- package/src/jwt/jwt.sql +177 -0
- package/src/oauth/index.ts +7 -0
- package/src/oauth/providers/apple.ts +43 -0
- package/src/oauth/providers/google.ts +49 -0
- package/src/oauth/types.ts +17 -0
- package/src/oauth/verify.ts +193 -0
- package/src/oauth.ts +6 -0
- package/src/passwords.ts +70 -0
- package/src/postgres.ts +504 -0
- package/src/refresh.ts +204 -0
- package/src/response.ts +50 -0
- package/src/schema.ts +224 -0
- package/src/sql.ts +261 -0
package/README.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# @everystack/auth
|
|
2
|
+
|
|
3
|
+
JWT auth for Expo apps. Zero runtime dependencies. SQL-compatible. Edge-verifiable.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Most JWT libraries (`jose`, `jsonwebtoken`, ...) are large dependencies that:
|
|
8
|
+
|
|
9
|
+
1. Don't run in CloudFront Functions (no `Buffer`, `TextEncoder`, `crypto.subtle`)
|
|
10
|
+
2. Aren't byte-compatible with a Postgres-only implementation
|
|
11
|
+
3. Pull in WebCrypto polyfills you don't need on Node
|
|
12
|
+
|
|
13
|
+
This package provides one HS256 envelope that runs in three places — Node, Postgres, and CloudFront Functions — and produces byte-identical tokens in all three.
|
|
14
|
+
|
|
15
|
+
## Three Deployment Modes
|
|
16
|
+
|
|
17
|
+
| Mode | Mint tokens | Verify tokens | Use when |
|
|
18
|
+
|------|-------------|---------------|----------|
|
|
19
|
+
| **JS-only** | Lambda (Node) | Lambda + CFF (edge) | Standard Expo + SST app |
|
|
20
|
+
| **SQL-only** | Postgres (`jwt_sign`) | Postgres (`jwt_verify`) | Pure PostgREST, no Lambda |
|
|
21
|
+
| **Hybrid** | Either | Either | DB issues long-lived refresh, edge verifies short-lived access |
|
|
22
|
+
|
|
23
|
+
All three produce the same wire format: `header.payload.signature`, base64url, no padding, HS256.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm add @everystack/auth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Zero runtime dependencies. `drizzle-orm` and `react` are optional peer deps for the schema and client exports.
|
|
32
|
+
|
|
33
|
+
## Entry Points
|
|
34
|
+
|
|
35
|
+
| Export | Purpose |
|
|
36
|
+
|--------|---------|
|
|
37
|
+
| `@everystack/auth` | High-level handlers: `createAuthHandlers`, `verifyToken`, OAuth providers |
|
|
38
|
+
| `@everystack/auth/jwt` | Low-level `createJwt({ secret, hmac })` — runtime-agnostic envelope |
|
|
39
|
+
| `@everystack/auth/jwt/node` | `hmacNode` — HMAC backed by `node:crypto` |
|
|
40
|
+
| `@everystack/auth/jwt/web` | `hmacWeb` — HMAC backed by `crypto.subtle` |
|
|
41
|
+
| `@everystack/auth/jwt/sql` | SQL helpers for the Postgres twin (`jwt_sign`, `jwt_verify`) |
|
|
42
|
+
| `@everystack/auth/oauth` | `verifyOidcToken` (RS256+JWKS), Google, Apple |
|
|
43
|
+
| `@everystack/auth/cff` | `generateCffVerifier()` — emits a verifier as a string for CloudFront Functions |
|
|
44
|
+
| `@everystack/auth/client` | React `AuthProvider`, `useAuth` |
|
|
45
|
+
| `@everystack/auth/api` | API client wired with auto-refresh |
|
|
46
|
+
| `@everystack/auth/schema` | Drizzle schema for `refresh_tokens`, `oauth_accounts` |
|
|
47
|
+
|
|
48
|
+
## The Envelope
|
|
49
|
+
|
|
50
|
+
Library owns:
|
|
51
|
+
|
|
52
|
+
- `{"alg":"HS256","typ":"JWT"}` header (literal, not negotiated)
|
|
53
|
+
- base64url encoding (RFC 4648 §5, no padding)
|
|
54
|
+
- `iat` / `exp` math, configurable TTL and clock skew
|
|
55
|
+
- Optional `jti`
|
|
56
|
+
- Constant-time signature compare
|
|
57
|
+
- Algorithm pinning (rejects `alg=none`, `alg=HS512`, RSA confusion)
|
|
58
|
+
|
|
59
|
+
App owns:
|
|
60
|
+
|
|
61
|
+
- Claim shape (`sub` / `user_id` / whatever your DB uses)
|
|
62
|
+
- Role accessor (where to read role from claims)
|
|
63
|
+
- Subject accessor
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { createJwt } from '@everystack/auth/jwt';
|
|
67
|
+
import { hmacNode } from '@everystack/auth/jwt/node';
|
|
68
|
+
|
|
69
|
+
const jwt = createJwt({
|
|
70
|
+
secret: process.env.JWT_SECRET!,
|
|
71
|
+
hmac: hmacNode,
|
|
72
|
+
ttlSec: 3600,
|
|
73
|
+
clockSkewSec: 30,
|
|
74
|
+
includeJti: true,
|
|
75
|
+
// Optional accessors so the verifier knows where to look in your custom claim shape:
|
|
76
|
+
getSubject: (c) => c.user_id ?? c.sub,
|
|
77
|
+
getRole: (c) => c.role,
|
|
78
|
+
getExpiry: (c) => c.exp,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const token = await jwt.sign({ user_id: 'u_123', role: 'admin', email: 'a@b.c' });
|
|
82
|
+
const claims = await jwt.verify(token); // null if invalid/expired
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## High-Level Handlers
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { createAuthHandlers, createAppleProvider, createGoogleProvider } from '@everystack/auth';
|
|
89
|
+
import { refreshTokens, oauthAccounts } from '@everystack/auth/schema';
|
|
90
|
+
import { createDb, getJwtSecret } from '@everystack/server/db';
|
|
91
|
+
import * as schema from '../db/schema';
|
|
92
|
+
|
|
93
|
+
const { db } = createDb(schema);
|
|
94
|
+
const auth = createAuthHandlers(
|
|
95
|
+
db,
|
|
96
|
+
{ ...schema, refreshTokens, oauthAccounts },
|
|
97
|
+
getJwtSecret(),
|
|
98
|
+
{
|
|
99
|
+
oauth: {
|
|
100
|
+
providers: [
|
|
101
|
+
createAppleProvider({ clientId: 'com.yourapp.id' }),
|
|
102
|
+
createGoogleProvider({ clientId: 'your-client-id.apps.googleusercontent.com' }),
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Handler | Method | Body | Returns |
|
|
110
|
+
|---------|--------|------|---------|
|
|
111
|
+
| `signup` | POST | `{ email, password, username, displayName }` | `{ token, refreshToken, user }` |
|
|
112
|
+
| `signin` | POST | `{ email, password }` | `{ token, refreshToken, user }` |
|
|
113
|
+
| `refresh` | POST | `{ refreshToken }` | `{ token, refreshToken }` |
|
|
114
|
+
| `oauth` | POST | `{ provider, token }` | `{ token, refreshToken, user }` |
|
|
115
|
+
| `verifyToken` | — | token string | `TokenPayload \| null` |
|
|
116
|
+
| `revokeRefreshTokens` | — | `userId` | `void` |
|
|
117
|
+
|
|
118
|
+
Defaults: HS256, access TTL 1h, refresh TTL 7d.
|
|
119
|
+
|
|
120
|
+
## OAuth (RS256 + JWKS)
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { verifyOidcToken, createJwksCache } from '@everystack/auth/oauth';
|
|
124
|
+
|
|
125
|
+
const jwks = createJwksCache({
|
|
126
|
+
fetcher: () => fetch('https://www.googleapis.com/oauth2/v3/certs').then(r => r.json()),
|
|
127
|
+
ttlMs: 3600_000,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const claims = await verifyOidcToken(idToken, {
|
|
131
|
+
issuer: ['https://accounts.google.com', 'accounts.google.com'],
|
|
132
|
+
audience: process.env.GOOGLE_CLIENT_ID!,
|
|
133
|
+
jwks,
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The `jwks` cache deduplicates inflight fetches and force-refreshes when an unknown `kid` is seen. Algorithm is pinned to RS256.
|
|
138
|
+
|
|
139
|
+
Built-in providers (`createGoogleProvider`, `createAppleProvider`) wrap the above with the right issuer/JWKS URI and map provider claims to a uniform `OAuthUserInfo`.
|
|
140
|
+
|
|
141
|
+
## Edge Verification (CloudFront Functions)
|
|
142
|
+
|
|
143
|
+
CloudFront Functions run a strict ES2019 subset — no `Buffer`, no `TextEncoder`, no `atob`/`btoa`, no `crypto.subtle`. Only `crypto.createHmac('sha256').digest('base64')` is available.
|
|
144
|
+
|
|
145
|
+
`generateCffVerifier()` emits a self-contained verifier as a string you can embed in your CFF source:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
import { generateCffVerifier } from '@everystack/auth/cff';
|
|
149
|
+
|
|
150
|
+
const source = generateCffVerifier({
|
|
151
|
+
functionName: 'verifyJwt',
|
|
152
|
+
clockSkewSec: 30,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// `source` is portable JS — concatenate into your CFF function file.
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The emitted `verifyJwt(token, secret)` returns `{ payload }` or throws. It pins HS256, requires `typ:"JWT"`, requires numeric `exp`, and applies clock skew.
|
|
159
|
+
|
|
160
|
+
For SST users, `@everystack/server/cdn` provides a higher-level helper:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { createAuthFunction } from '@everystack/server/cdn';
|
|
164
|
+
|
|
165
|
+
const auth = createAuthFunction({
|
|
166
|
+
secret: $output(secret.value),
|
|
167
|
+
publicPaths: ['/api/auth/', '/api/health', '/api/updates'],
|
|
168
|
+
roles: {
|
|
169
|
+
posts: { read: 'public', write: 'authenticated' },
|
|
170
|
+
comments: { read: 'authenticated', write: 'authenticated' },
|
|
171
|
+
'*': { read: 'authenticated', write: 'admin' },
|
|
172
|
+
},
|
|
173
|
+
roleHierarchy: ['public', 'authenticated', 'admin'],
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
new sst.aws.Router('Router', {
|
|
177
|
+
edge: { viewerRequest: { injection: auth.injection } },
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The injection runs at the edge before any Lambda hop. It returns 401/403 directly when auth fails — no cold start, no API Gateway round trip.
|
|
182
|
+
|
|
183
|
+
### Edge revocation tradeoff
|
|
184
|
+
|
|
185
|
+
Edge verification trusts the signature; it cannot consult a revocation list without a network call (which defeats the point). Mitigations:
|
|
186
|
+
|
|
187
|
+
- Keep **access tokens short** (≤ 15 min recommended for edge-verified setups)
|
|
188
|
+
- Keep **refresh tokens long-lived in DB** with revocation
|
|
189
|
+
- Sign-out / password change → revoke refresh tokens, access tokens expire on their own
|
|
190
|
+
|
|
191
|
+
## SQL Twin
|
|
192
|
+
|
|
193
|
+
`@everystack/auth/jwt/sql` ships pgcrypto-based functions that produce byte-identical tokens to the JS path:
|
|
194
|
+
|
|
195
|
+
- `jwt_sign(claims jsonb, secret text)` → `text`
|
|
196
|
+
- `jwt_verify(token text, secret text)` → `jsonb`
|
|
197
|
+
|
|
198
|
+
Use this if you want PostgREST to mint tokens directly from a `sign_in` SQL function, or to verify tokens inside RLS policies. Conformance tests guarantee byte-identical output for the same `(claims, secret)` pair.
|
|
199
|
+
|
|
200
|
+
## Client
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { AuthProvider, useAuth } from '@everystack/auth/client';
|
|
204
|
+
|
|
205
|
+
function App() {
|
|
206
|
+
return <AuthProvider><YourApp /></AuthProvider>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function Login() {
|
|
210
|
+
const { signIn, signUp, signOut, user, isLoading } = useAuth();
|
|
211
|
+
// ...
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`storage` exposes `getToken()`, `getRefresh()`, `getUser()`, `save()`, `clear()`. Keys: `everystack-token`, `everystack-refresh`, `everystack-user`.
|
|
216
|
+
|
|
217
|
+
## API Client (`@everystack/auth/api`)
|
|
218
|
+
|
|
219
|
+
Pre-configured `@everystack/api/client` instance:
|
|
220
|
+
|
|
221
|
+
- Auto-injects `Authorization: Bearer ...`
|
|
222
|
+
- On 401 → calls `/api/auth/refresh`, deduplicates concurrent refreshes, retries original request
|
|
223
|
+
- `rpc<T>(name, body?)` for `/api/rpc/*` calls
|
|
224
|
+
|
|
225
|
+
## Password Hashing
|
|
226
|
+
|
|
227
|
+
- `hashPassword(password)` — bcrypt cost 12 (`bcryptjs`, pure JS). Throws on empty input or >72 UTF-8 bytes (no silent truncation).
|
|
228
|
+
- `verifyPassword(password, stored)` — accepts `$2a$` / `$2b$` / `$2y$` prefixes; returns `false` (never throws) on malformed input.
|
|
229
|
+
- SQL twin: byte-compatible with pgcrypto `crypt(p, gen_salt('bf', 12))`.
|
|
230
|
+
|
|
231
|
+
## Types
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
interface TokenPayload {
|
|
235
|
+
sub: string;
|
|
236
|
+
email: string;
|
|
237
|
+
role: string;
|
|
238
|
+
// ...your custom claims
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Peer Dependencies
|
|
243
|
+
|
|
244
|
+
- `drizzle-orm` (optional) — only for `@everystack/auth/schema` and the high-level handlers
|
|
245
|
+
- `react` (optional) — only for `@everystack/auth/client`
|
|
246
|
+
- `@everystack/api` (optional) — only for `@everystack/auth/api`
|
|
247
|
+
|
|
248
|
+
No JWT library dependency.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JWT authentication handlers, password hashing, and React auth context",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"types": "./src/client/index.tsx",
|
|
21
|
+
"default": "./src/client/index.tsx"
|
|
22
|
+
},
|
|
23
|
+
"./api": {
|
|
24
|
+
"types": "./src/api.ts",
|
|
25
|
+
"default": "./src/api.ts"
|
|
26
|
+
},
|
|
27
|
+
"./schema": {
|
|
28
|
+
"types": "./src/schema.ts",
|
|
29
|
+
"default": "./src/schema.ts"
|
|
30
|
+
},
|
|
31
|
+
"./sql": {
|
|
32
|
+
"types": "./src/sql.ts",
|
|
33
|
+
"default": "./src/sql.ts"
|
|
34
|
+
},
|
|
35
|
+
"./postgres": {
|
|
36
|
+
"types": "./src/postgres.ts",
|
|
37
|
+
"default": "./src/postgres.ts"
|
|
38
|
+
},
|
|
39
|
+
"./jwt": {
|
|
40
|
+
"types": "./src/jwt/index.ts",
|
|
41
|
+
"default": "./src/jwt/index.ts"
|
|
42
|
+
},
|
|
43
|
+
"./jwt/node": {
|
|
44
|
+
"types": "./src/jwt/hmac-node.ts",
|
|
45
|
+
"default": "./src/jwt/hmac-node.ts"
|
|
46
|
+
},
|
|
47
|
+
"./jwt/web": {
|
|
48
|
+
"types": "./src/jwt/hmac-web.ts",
|
|
49
|
+
"default": "./src/jwt/hmac-web.ts"
|
|
50
|
+
},
|
|
51
|
+
"./jwt/sql": {
|
|
52
|
+
"default": "./src/jwt/jwt.sql"
|
|
53
|
+
},
|
|
54
|
+
"./oauth": {
|
|
55
|
+
"types": "./src/oauth/index.ts",
|
|
56
|
+
"default": "./src/oauth/index.ts"
|
|
57
|
+
},
|
|
58
|
+
"./cookies": {
|
|
59
|
+
"types": "./src/cookies.ts",
|
|
60
|
+
"default": "./src/cookies.ts"
|
|
61
|
+
},
|
|
62
|
+
"./context": {
|
|
63
|
+
"types": "./src/context.ts",
|
|
64
|
+
"default": "./src/context.ts"
|
|
65
|
+
},
|
|
66
|
+
"./cff": {
|
|
67
|
+
"types": "./src/cff/index.ts",
|
|
68
|
+
"default": "./src/cff/index.ts"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"bcryptjs": "^3.0.3"
|
|
73
|
+
},
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"@everystack/api": ">=0.1.0",
|
|
76
|
+
"drizzle-orm": ">=0.30.0",
|
|
77
|
+
"react": ">=18.0.0"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"react": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@everystack/api": {
|
|
84
|
+
"optional": true
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@types/bcryptjs": "^3.0.0",
|
|
89
|
+
"@types/jest": "^29.5.14",
|
|
90
|
+
"@types/node": "^22.0.0",
|
|
91
|
+
"@types/react": "^19.0.0",
|
|
92
|
+
"drizzle-orm": "^0.41.0",
|
|
93
|
+
"jest": "^29.7.0",
|
|
94
|
+
"postgres": "^3.4.0",
|
|
95
|
+
"ts-jest": "^29.3.0",
|
|
96
|
+
"typescript": "^5.7.0",
|
|
97
|
+
"@everystack/api": "0.1.0"
|
|
98
|
+
},
|
|
99
|
+
"scripts": {
|
|
100
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
101
|
+
"build": "tsc --build",
|
|
102
|
+
"lint": "tsc --noEmit"
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/auth/api — Pre-configured API client with auth token management.
|
|
3
|
+
*
|
|
4
|
+
* Token storage keys match the auth client exactly so the API client
|
|
5
|
+
* always reads the latest token after signin/signup.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { api, rpc } from '@everystack/auth/api';
|
|
9
|
+
*
|
|
10
|
+
* const { data } = await api.from('posts').select();
|
|
11
|
+
* const result = await rpc('timeline', { limit: 20 });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createClient } from '@everystack/api/client';
|
|
15
|
+
|
|
16
|
+
const TOKEN_KEY = 'everystack-token';
|
|
17
|
+
const REFRESH_KEY = 'everystack-refresh';
|
|
18
|
+
|
|
19
|
+
export const api = createClient({
|
|
20
|
+
baseUrl: '/api',
|
|
21
|
+
getToken: () => {
|
|
22
|
+
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
23
|
+
},
|
|
24
|
+
onTokenExpired: async () => {
|
|
25
|
+
try {
|
|
26
|
+
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
|
27
|
+
if (!refreshToken) return null;
|
|
28
|
+
const res = await fetch('/api/auth/refresh', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ refreshToken }),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) return null;
|
|
34
|
+
const body = await res.json();
|
|
35
|
+
localStorage.setItem(TOKEN_KEY, body.token);
|
|
36
|
+
localStorage.setItem(REFRESH_KEY, body.refreshToken);
|
|
37
|
+
return body.token;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Call an RPC function on the handler.
|
|
44
|
+
*/
|
|
45
|
+
export async function rpc<T = unknown>(name: string, body?: unknown): Promise<T> {
|
|
46
|
+
const token = (() => {
|
|
47
|
+
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
const res = await fetch(`/api/rpc/${name}`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
55
|
+
},
|
|
56
|
+
body: body ? JSON.stringify(body) : '{}',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const err = await res.json().catch(() => ({}));
|
|
61
|
+
throw new Error(err.message || `RPC ${name} failed: ${res.status}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return res.json();
|
|
65
|
+
}
|
package/src/cff/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/auth/cff — CloudFront Functions code generator.
|
|
3
|
+
*
|
|
4
|
+
* Emits a self-contained ES2019 source string that runs inside the CloudFront
|
|
5
|
+
* Functions runtime 2.0. Verifies HS256 JWTs minted by @everystack/auth/jwt
|
|
6
|
+
* (or by the SQL twin) and returns the decoded claims object, or null on any
|
|
7
|
+
* failure (tampered, expired, wrong alg, malformed, missing exp).
|
|
8
|
+
*
|
|
9
|
+
* The emitted code uses ONLY:
|
|
10
|
+
* - Built-in `crypto.createHmac('sha256', secret)` (CFF runtime 2.0 builtin)
|
|
11
|
+
* - Native String / JSON / Math / Date / parseInt
|
|
12
|
+
*
|
|
13
|
+
* It does NOT use Buffer, TextEncoder, atob, btoa, or any Node API.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface CffGeneratorOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Optional: name to give the exported verifier. Default 'verifyJwt'.
|
|
19
|
+
* Useful when bundling multiple verifiers in a single CFF.
|
|
20
|
+
*/
|
|
21
|
+
functionName?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Optional: maximum allowed clock skew (seconds) when checking exp. Default 30.
|
|
24
|
+
*/
|
|
25
|
+
clockSkewSec?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compact verifier — same API as generateCffVerifier but ~1,200 bytes raw
|
|
30
|
+
* instead of ~3,700. Trades full UTF-8 decoding for ASCII-only
|
|
31
|
+
* String.fromCharCode (safe because JWT claims are always ASCII).
|
|
32
|
+
*
|
|
33
|
+
* Use this when the CloudFront Function size budget is tight.
|
|
34
|
+
*/
|
|
35
|
+
export function generateCompactCffVerifier(opts: CffGeneratorOptions = {}): string {
|
|
36
|
+
const fnName = opts.functionName ?? 'verifyJwt';
|
|
37
|
+
const skew = Number.isFinite(opts.clockSkewSec) ? Math.max(0, Math.floor(opts.clockSkewSec!)) : 30;
|
|
38
|
+
|
|
39
|
+
return `var _B64="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
40
|
+
var crypto=require("crypto");
|
|
41
|
+
function _b64d(s){var o=[],i=0,n=s.length;while(i<n){var a=_B64.indexOf(s.charAt(i++)),b=_B64.indexOf(s.charAt(i++)),c=i<n?_B64.indexOf(s.charAt(i++)):-1,d=i<n?_B64.indexOf(s.charAt(i++)):-1;if(a<0||b<0)return"";o.push((a<<2)|(b>>4));if(c>=0)o.push(((b&15)<<4)|(c>>2));if(d>=0)o.push(((c&3)<<6)|d)}return String.fromCharCode.apply(null,o)}
|
|
42
|
+
function ${fnName}(token,secret){if(typeof token!=="string"||!token)return null;var p=token.split(".");if(p.length!==3)return null;var h;try{h=JSON.parse(_b64d(p[0]))}catch(e){return null}if(!h||h.alg!=="HS256"||h.typ!=="JWT")return null;var exp=crypto.createHmac("sha256",secret).update(p[0]+"."+p[1]).digest("base64").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");if(exp.length!==p[2].length)return null;var df=0;for(var i=0;i<exp.length;i++)df|=exp.charCodeAt(i)^p[2].charCodeAt(i);if(df!==0)return null;var c;try{c=JSON.parse(_b64d(p[1]))}catch(e){return null}if(!c||typeof c!=="object")return null;if(typeof c.exp!=="number")return null;if(Math.floor(Date.now()/1000)>c.exp+${skew})return null;return c}
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function generateCffVerifier(opts: CffGeneratorOptions = {}): string {
|
|
47
|
+
const fnName = opts.functionName ?? 'verifyJwt';
|
|
48
|
+
const skew = Number.isFinite(opts.clockSkewSec) ? Math.max(0, Math.floor(opts.clockSkewSec!)) : 30;
|
|
49
|
+
|
|
50
|
+
return `// @everystack/auth/cff — generated HS256 verifier (CloudFront Functions runtime 2.0).
|
|
51
|
+
// Returns claims object on success, null on any failure.
|
|
52
|
+
// Pure ES2019 + crypto.createHmac('sha256', secret).digest('base64').
|
|
53
|
+
var crypto = require('crypto');
|
|
54
|
+
|
|
55
|
+
function ${fnName}(token, secret) {
|
|
56
|
+
if (typeof token !== 'string' || token.length === 0) return null;
|
|
57
|
+
var parts = token.split('.');
|
|
58
|
+
if (parts.length !== 3) return null;
|
|
59
|
+
var headerB64 = parts[0];
|
|
60
|
+
var payloadB64 = parts[1];
|
|
61
|
+
var sigB64 = parts[2];
|
|
62
|
+
|
|
63
|
+
// 1. Header check — must be exactly HS256+JWT.
|
|
64
|
+
var header;
|
|
65
|
+
try { header = JSON.parse(b64uDecodeToString(headerB64)); } catch (e) { return null; }
|
|
66
|
+
if (!header || header.alg !== 'HS256' || header.typ !== 'JWT') return null;
|
|
67
|
+
|
|
68
|
+
// 2. Recompute signature and constant-time compare.
|
|
69
|
+
var expected = b64ToB64u(
|
|
70
|
+
crypto.createHmac('sha256', secret).update(headerB64 + '.' + payloadB64).digest('base64')
|
|
71
|
+
);
|
|
72
|
+
if (!ctEq(expected, sigB64)) return null;
|
|
73
|
+
|
|
74
|
+
// 3. Decode payload and validate exp.
|
|
75
|
+
var claims;
|
|
76
|
+
try { claims = JSON.parse(b64uDecodeToString(payloadB64)); } catch (e) { return null; }
|
|
77
|
+
if (!claims || typeof claims !== 'object') return null;
|
|
78
|
+
if (typeof claims.exp !== 'number') return null;
|
|
79
|
+
var now = Math.floor(Date.now() / 1000);
|
|
80
|
+
if (now > claims.exp + ${skew}) return null;
|
|
81
|
+
|
|
82
|
+
return claims;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// base64 -> base64url (RFC 4648 §5, no padding).
|
|
86
|
+
function b64ToB64u(s) {
|
|
87
|
+
return s.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// base64url -> string (UTF-8 decode of bytes).
|
|
91
|
+
// CFF runtime 2.0 has no atob; we decode manually.
|
|
92
|
+
var B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
93
|
+
function b64uDecodeToString(input) {
|
|
94
|
+
// Build a lookup table once (cached on function).
|
|
95
|
+
if (!b64uDecodeToString._lut) {
|
|
96
|
+
var lut = {};
|
|
97
|
+
for (var i = 0; i < B64_ALPHABET.length; i++) lut[B64_ALPHABET.charAt(i)] = i;
|
|
98
|
+
b64uDecodeToString._lut = lut;
|
|
99
|
+
}
|
|
100
|
+
var lut = b64uDecodeToString._lut;
|
|
101
|
+
var bytes = [];
|
|
102
|
+
var len = input.length;
|
|
103
|
+
var i = 0;
|
|
104
|
+
while (i < len) {
|
|
105
|
+
var c1 = lut[input.charAt(i++)];
|
|
106
|
+
var c2 = lut[input.charAt(i++)];
|
|
107
|
+
var c3 = i < len ? lut[input.charAt(i++)] : -1;
|
|
108
|
+
var c4 = i < len ? lut[input.charAt(i++)] : -1;
|
|
109
|
+
if (c1 === undefined || c2 === undefined) return '';
|
|
110
|
+
bytes.push((c1 << 2) | (c2 >> 4));
|
|
111
|
+
if (c3 !== -1 && c3 !== undefined) bytes.push(((c2 & 15) << 4) | (c3 >> 2));
|
|
112
|
+
if (c4 !== -1 && c4 !== undefined) bytes.push(((c3 & 3) << 6) | c4);
|
|
113
|
+
}
|
|
114
|
+
return utf8BytesToString(bytes);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// UTF-8 byte array -> JS string.
|
|
118
|
+
function utf8BytesToString(bytes) {
|
|
119
|
+
var out = '';
|
|
120
|
+
var i = 0;
|
|
121
|
+
var n = bytes.length;
|
|
122
|
+
while (i < n) {
|
|
123
|
+
var b1 = bytes[i++];
|
|
124
|
+
if (b1 < 0x80) {
|
|
125
|
+
out += String.fromCharCode(b1);
|
|
126
|
+
} else if (b1 < 0xc0) {
|
|
127
|
+
// continuation byte without leader — invalid; emit replacement.
|
|
128
|
+
out += '\\uFFFD';
|
|
129
|
+
} else if (b1 < 0xe0) {
|
|
130
|
+
var b2 = bytes[i++] & 0x3f;
|
|
131
|
+
out += String.fromCharCode(((b1 & 0x1f) << 6) | b2);
|
|
132
|
+
} else if (b1 < 0xf0) {
|
|
133
|
+
var c2 = bytes[i++] & 0x3f;
|
|
134
|
+
var c3 = bytes[i++] & 0x3f;
|
|
135
|
+
out += String.fromCharCode(((b1 & 0x0f) << 12) | (c2 << 6) | c3);
|
|
136
|
+
} else {
|
|
137
|
+
// 4-byte sequence -> surrogate pair.
|
|
138
|
+
var d2 = bytes[i++] & 0x3f;
|
|
139
|
+
var d3 = bytes[i++] & 0x3f;
|
|
140
|
+
var d4 = bytes[i++] & 0x3f;
|
|
141
|
+
var cp = ((b1 & 0x07) << 18) | (d2 << 12) | (d3 << 6) | d4;
|
|
142
|
+
cp -= 0x10000;
|
|
143
|
+
out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Constant-time string compare.
|
|
150
|
+
function ctEq(a, b) {
|
|
151
|
+
if (a.length !== b.length) return false;
|
|
152
|
+
var diff = 0;
|
|
153
|
+
for (var i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
154
|
+
return diff === 0;
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
}
|