forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmiddleware.ts
More file actions
99 lines (92 loc) · 3.69 KB
/
Copy pathmiddleware.ts
File metadata and controls
99 lines (92 loc) · 3.69 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
import type { MiddlewareHandler } from 'hono'
import { PrivyAuthError, bearerToken, type PrivyAuth, type PrivyWallet } from './privy.js'
import { upsertUser, type UserDb } from './session.js'
/**
* Module augmentation rather than a Hono generic on every app and sub-app.
* Threading `Hono<SessionVars>` through app.ts would make the root app's type
* depend on auth, and the two surfaces would stop being interchangeable in the
* host router. These variables are set by requireSession and nothing else.
*/
declare module 'hono' {
interface ContextVariableMap {
/** Ottopus user id — the foreign key everything else hangs off. */
userId: string
/** The Privy DID behind it, for logs and for support questions. */
privyDid: string
/** The stored user — what we know, not what the caller claims. */
user: { id: string; privyDid: string; email: string | null; name: string | null }
/**
* Wallets from a *verified* identity token, when the caller sent one.
*
* Undefined and empty mean different things, and routes must not confuse
* them: undefined is "the caller told us nothing", empty is "Privy says
* this user has no wallets". Reconciling on the first would unlink
* everything the moment a request arrives without the header.
*/
privyWallets: PrivyWallet[] | undefined
}
}
export interface SessionOptions {
auth: PrivyAuth
db: UserDb
}
/**
* Privy's identity token, when the caller sends one. Optional by design: the
* access token alone is enough to know who you are, and the identity token
* only adds what you are called.
*/
const IDENTITY_HEADER = 'X-Privy-Identity-Token'
/**
* Requires a signed-in user, and resolves them to an Ottopus user id.
*
* Two things happen per request, and both have to: the token is verified, and
* the DID is exchanged for a user id. Nothing downstream reads the token, so
* there is no path where a route accidentally trusts an unverified claim.
*
* Errors are deliberately uniform. A caller learns that they are not signed in,
* never why — "expired" and "not a real token" are the same 401, because the
* difference is only useful to someone probing.
*/
export function requireSession({ auth, db }: SessionOptions): MiddlewareHandler {
return async (c, next) => {
const token = bearerToken(c.req.header('Authorization'))
if (!token) {
return c.json({ error: 'unauthorized' }, 401, { 'WWW-Authenticate': 'Bearer' })
}
let did: string
try {
;({ did } = await auth.verifyAccess(token))
} catch (err) {
if (err instanceof PrivyAuthError) {
return c.json({ error: 'unauthorized' }, 401, { 'WWW-Authenticate': 'Bearer' })
}
throw err
}
// A bad identity token is not a failed sign-in — the access token already
// proved who this is. It only means we learn no name this time.
let profile = {}
let wallets: PrivyWallet[] | undefined
const identity = c.req.header(IDENTITY_HEADER)
if (identity) {
try {
const read = await auth.readIdentity(identity)
// The DID check is the load-bearing line. Both tokens verify against
// the same key, so a valid identity token for *another* user would
// otherwise pass its wallets off as this caller's — and the sync route
// would link them to the wrong account.
if (read.did === did) {
profile = { email: read.email, name: read.name }
wallets = read.wallets
}
} catch {
// Ignored on purpose. See above.
}
}
const user = await upsertUser(db, did, profile)
c.set('userId', user.id)
c.set('privyDid', did)
c.set('user', user)
c.set('privyWallets', wallets)
await next()
}
}