forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprivy.ts
More file actions
291 lines (266 loc) · 11.1 KB
/
Copy pathprivy.ts
File metadata and controls
291 lines (266 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import * as jose from 'jose'
/**
* Privy access-token verification.
*
* Offline, against the app's public verification key: no network call on the
* hot path, no app secret to hold, and nothing to be down when Privy is. The
* key is a public ES256 key from the Privy dashboard — safe in an environment
* variable in a way an app secret is not.
*
* Two tokens, two jobs. The **access** token is the credential and answers "who
* is calling" — `sub`, and nothing else. The **identity** token is an assertion
* about that user, and carries the `linked_accounts` claim.
*
* Both are signed by the same public ES256 key, which is what makes the wallet
* addresses in `linked_accounts` usable server-side. They are not a client
* claim: the browser cannot mint one, so a caller cannot name an address they
* do not control. What they are is *Privy's* attestation — Privy ran the
* EIP-4361 exchange (`/siwe/init` for the nonce, `/siwe/link` to verify the
* signature) and is telling us it passed. See wallets/reconcile.ts for what
* that does and does not buy.
*/
export class PrivyAuthError extends Error {}
/**
* Says why a verification key is unusable, or null when it looks fine.
*
* The dashboard hands you a multi-line PEM, and pasting that straight into a
* .env without quotes leaves `-----BEGIN PUBLIC KEY-----` and nothing else —
* the parser stops at the first newline. That failure otherwise surfaces as
* every request returning 401, which sends you looking at tokens instead of at
* the one line of config that is actually wrong.
*/
export function keyProblem(raw: string | undefined): string | null {
const key = (raw ?? '').trim()
if (!key) return 'not set'
if (key.startsWith('{')) {
try {
JSON.parse(key)
return null
} catch {
return 'looks like a JWK but is not valid JSON'
}
}
const hasBegin = key.includes('BEGIN PUBLIC KEY')
const hasEnd = key.includes('END PUBLIC KEY')
if (hasBegin && !hasEnd) {
return 'truncated after the BEGIN line — quote the value in .env ("-----BEGIN…"), or put it on one line with \\n escapes'
}
if (!hasBegin && !hasEnd && key.length < 40) {
return `too short to be a public key (${key.length} characters)`
}
return null
}
export interface PrivyClaims {
/** The user's Privy DID, e.g. did:privy:xxxxx. The join key on users. */
did: string
/** Seconds since the epoch. */
expiresAt: number
}
/**
* A wallet Privy says this user has linked and proved.
*
* Field names mirror the claim, not our schema — the mapping onto an arm is
* the wallets module's job, so a change in Privy's shape lands in one place.
*/
export interface PrivyWallet {
/** Lowercased here, because every comparison downstream is lowercase. */
address: string
/** metamask, rabby, coinbase_wallet, privy — this is what names the arm. */
walletClientType?: string
/** injected, wallet_connect — how it was reached, not what it is. */
connectorType?: string
/** 'ethereum' or 'solana'. Anything but ethereum is skipped for now. */
chainType?: string
/** When Privy first verified it, as an ISO string. */
firstVerifiedAt?: string
/** When Privy last saw it prove itself. */
latestVerifiedAt?: string
}
/** What an identity token tells us: who you are called, and what you linked. */
export interface PrivyIdentity {
did: string
email?: string
name?: string
/**
* Undefined when the token carried no readable `linked_accounts` claim — an
* access token sent here by mistake, or a shape we do not recognise. Never
* treat it as an empty list: callers that reconcile against this must be able
* to tell "no wallets" from "this token does not say".
*/
wallets: PrivyWallet[] | undefined
}
export interface PrivyVerifierConfig {
appId: string
/** SPKI PEM or a JWK, as the dashboard gives it. */
verificationKey: string
}
/**
* Privy issues ES256. Pinning the algorithm matters: without it a token could
* name its own, and "none" or an HMAC over the public key would both verify.
*/
const ALGORITHM = 'ES256'
export type PrivyVerifier = (token: string) => Promise<PrivyClaims>
export interface PrivyAuth {
/** The credential. Proves who is calling, and nothing else. */
verifyAccess: PrivyVerifier
/**
* The profile and the linked wallets. Signed by the same key, so everything
* in it is attested by Privy rather than typed by the caller.
*/
readIdentity: (token: string) => Promise<PrivyIdentity>
}
/**
* The claim is documented only as "a lightweight version of linkedAccounts",
* and the SDK's own types are camelCase while the JWT is snake_case. Both
* spellings are read rather than betting on one — the same reason `nameOf`
* below does, and the cost of guessing wrong is a wallet that silently never
* syncs.
*/
interface LinkedAccount {
type?: string
address?: string
email?: string
name?: string
first_name?: string
last_name?: string
username?: string
wallet_client_type?: string
walletClientType?: string
connector_type?: string
connectorType?: string
chain_type?: string
chainType?: string
first_verified_at?: string | number
firstVerifiedAt?: string | number
latest_verified_at?: string | number
latestVerifiedAt?: string | number
}
/** Privy sends these as ISO strings and as epoch seconds, depending on age. */
function timestampOf(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined
const date = typeof value === 'number' ? new Date(value * 1000) : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
/**
* A person's name, however the provider chose to spell it. Privy calls the
* claim "a lightweight version of linkedAccounts" without pinning the shape, so
* this reads the plausible spellings rather than betting on one.
*/
function nameOf(account: LinkedAccount): string | undefined {
if (account.name) return account.name
const full = [account.first_name, account.last_name].filter(Boolean).join(' ').trim()
return full || account.username || undefined
}
/**
* Privy has encoded this as a JSON string and as an array, depending on age.
*
* Null means "this token did not tell us" — the claim is absent, or present and
* unreadable. That is a different fact from an empty array, and conflating the
* two is dangerous: `linked_accounts` is what distinguishes an identity token
* from an access token, both are signed by the same key for the same issuer and
* audience, and an access token has no such claim. Returning [] for one would
* let an access token in the identity header assert "this user has no wallets",
* which the sync route would faithfully act on by unlinking all of them.
*/
function linkedAccounts(raw: unknown): LinkedAccount[] | null {
if (Array.isArray(raw)) return raw as LinkedAccount[]
if (typeof raw === 'string') {
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as LinkedAccount[]) : null
} catch {
return null
}
}
return null
}
/**
* The key is parsed once and reused. Import is the expensive part, and it
* cannot fail differently per request — if the key is malformed, every call
* should say the same thing.
*/
export function createPrivyAuth({ appId, verificationKey }: PrivyVerifierConfig): PrivyAuth {
// jose's own key type — a CryptoKey here, but importJWK widens it. Named
// from the library rather than from the DOM lib, which this package does not
// pull in.
type Key = Awaited<ReturnType<typeof jose.importJWK>>
const load = async (): Promise<Key> => {
// A single-line PEM with escaped newlines is how this value survives most
// secret stores, so unescape before anything else looks at it.
const trimmed = verificationKey.trim().replace(/\\n/g, '\n')
// The dashboard gives either shape depending on where you copy from.
if (trimmed.startsWith('{')) return jose.importJWK(JSON.parse(trimmed), ALGORITHM)
const pem = trimmed.includes('BEGIN PUBLIC KEY')
? trimmed
: `-----BEGIN PUBLIC KEY-----\n${trimmed}\n-----END PUBLIC KEY-----`
return jose.importSPKI(pem, ALGORITHM)
}
let keyPromise: Promise<Key> | undefined
const key = () => (keyPromise ??= load())
const claims = async (token: string): Promise<jose.JWTPayload> => {
try {
const result = await jose.jwtVerify(token, await key(), {
algorithms: [ALGORITHM],
issuer: 'privy.io',
audience: appId,
})
return result.payload
} catch (err) {
// One message for every failure. jose's error name says which check
// failed — expired, bad signature, wrong audience — and putting that in
// the message would leak it through any layer that logs or returns it.
// The detail survives as `cause` for server-side logs, which is where
// knowing the difference is actually useful.
throw new PrivyAuthError('Token rejected', { cause: err })
}
}
const didOf = (payload: jose.JWTPayload): string => {
const did = payload.sub
if (typeof did !== 'string' || !did.startsWith('did:privy:')) {
throw new PrivyAuthError('Token rejected', { cause: 'subject is not a Privy DID' })
}
return did
}
return {
async verifyAccess(token: string): Promise<PrivyClaims> {
const payload = await claims(token)
const did = didOf(payload)
if (typeof payload.exp !== 'number') {
throw new PrivyAuthError('Token rejected', { cause: 'no expiry' })
}
return { did, expiresAt: payload.exp }
},
async readIdentity(token: string): Promise<PrivyIdentity> {
const payload = await claims(token)
const did = didOf(payload)
const claimed = linkedAccounts(payload.linked_accounts)
const accounts = claimed ?? []
// A wallet's `address` is an account, not an inbox — reading names and
// email from wallet entries would put "0xabc…" in the name column.
const people = accounts.filter((a) => a.type !== 'wallet')
const named = people.map(nameOf).find(Boolean)
const mailed = people.find((a) => a.email)?.email
const emailAccount = accounts.find((a) => a.type === 'email' && a.address)?.address
const wallets = claimed
?.filter((a) => a.type === 'wallet' && typeof a.address === 'string' && a.address)
.map(
(a): PrivyWallet => ({
address: a.address!.toLowerCase(),
walletClientType: a.wallet_client_type ?? a.walletClientType,
connectorType: a.connector_type ?? a.connectorType,
chainType: a.chain_type ?? a.chainType,
firstVerifiedAt: timestampOf(a.first_verified_at ?? a.firstVerifiedAt),
latestVerifiedAt: timestampOf(a.latest_verified_at ?? a.latestVerifiedAt),
}),
)
return { did, name: named, email: mailed ?? emailAccount, wallets }
},
}
}
/** `Authorization: Bearer <token>`, or null. The scheme is case-insensitive
* per RFC 7235, and clients do send "bearer". */
export function bearerToken(header: string | undefined | null): string | null {
if (!header) return null
const match = /^Bearer +([A-Za-z0-9._~+/-]+=*)$/i.exec(header.trim())
return match ? match[1]! : null
}