Skip to content

Commit 5056625

Browse files
authored
feat: oauth server side and confidential client (#1366)
1 parent 4e6e168 commit 5056625

File tree

17 files changed

+212
-122
lines changed

17 files changed

+212
-122
lines changed

app/components/Header/AuthModal.client.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ watch(handleInput, newHandleInput => {
5050
handleInput.value = normalized
5151
}
5252
})
53+
54+
watch(user, async newUser => {
55+
if (newUser?.relogin) {
56+
await authRedirect(newUser.did, {
57+
redirectTo: route.fullPath,
58+
})
59+
}
60+
})
5361
</script>
5462

5563
<template>

nuxt.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export default defineNuxtConfig({
3737
github: {
3838
orgToken: '',
3939
},
40+
oauthJwkOne: process.env.OAUTH_JWK_ONE || undefined,
4041
// Upstash Redis for distributed OAuth token refresh locking in production
4142
upstash: {
4243
redisRestUrl: process.env.UPSTASH_KV_REST_API_URL || process.env.KV_REST_API_URL || '',
@@ -122,6 +123,7 @@ export default defineNuxtConfig({
122123
'/_avatar/**': { isr: 3600, proxy: 'https://www.gravatar.com/avatar/**' },
123124
'/opensearch.xml': { isr: true },
124125
'/oauth-client-metadata.json': { prerender: true },
126+
'/.well-known/jwks.json': { prerender: true },
125127
// never cache
126128
'/api/auth/**': { isr: false, cache: false },
127129
'/api/social/**': { isr: false, cache: false },

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"generate:sprite": "node scripts/generate-file-tree-sprite.ts",
3535
"generate:fixtures": "node scripts/generate-fixtures.ts",
3636
"generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear",
37+
"generate:jwk": "node scripts/gen-jwk.ts",
3738
"test": "vite test",
3839
"test:a11y": "pnpm build:test && LIGHTHOUSE_COLOR_MODE=dark pnpm test:a11y:prebuilt && LIGHTHOUSE_COLOR_MODE=light pnpm test:a11y:prebuilt",
3940
"test:a11y:prebuilt": "./scripts/lighthouse.sh",

scripts/gen-jwk.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { JoseKey } from '@atproto/oauth-client-node'
2+
3+
async function run() {
4+
const kid = Date.now().toString()
5+
const key = await JoseKey.generate(['ES256'], kid)
6+
const jwk = key.privateJwk
7+
8+
console.log(JSON.stringify(jwk))
9+
}
10+
11+
await run()

server/api/auth/atproto.get.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import type { OAuthSession } from '@atproto/oauth-client-node'
2-
import { NodeOAuthClient, OAuthCallbackError } from '@atproto/oauth-client-node'
2+
import { OAuthCallbackError } from '@atproto/oauth-client-node'
33
import { createError, getQuery, sendRedirect, setCookie, getCookie, deleteCookie } from 'h3'
44
import type { H3Event } from 'h3'
5-
import { getOAuthLock } from '#server/utils/atproto/lock'
6-
import { useOAuthStorage } from '#server/utils/atproto/storage'
75
import { SLINGSHOT_HOST } from '#shared/utils/constants'
86
import { useServerSession } from '#server/utils/server-session'
9-
import { handleResolver } from '#server/utils/atproto/oauth'
107
import { handleApiError } from '#server/utils/error-handler'
118
import type { DidString } from '@atproto/lex'
129
import { Client } from '@atproto/lex'
1310
import * as com from '#shared/types/lexicons/com'
1411
import * as app from '#shared/types/lexicons/app'
1512
import { isAtIdentifierString } from '@atproto/lex'
16-
import { scope, getOauthClientMetadata } from '#server/utils/atproto/oauth'
13+
import { scope } from '#server/utils/atproto/oauth'
1714
import { UNSET_NUXT_SESSION_PASSWORD } from '#shared/utils/constants'
1815
// @ts-expect-error virtual file from oauth module
1916
import { clientUri } from '#oauth/config'
@@ -28,17 +25,7 @@ export default defineEventHandler(async event => {
2825
}
2926

3027
const query = getQuery(event)
31-
const clientMetadata = getOauthClientMetadata()
3228
const session = await useServerSession(event)
33-
const { stateStore, sessionStore } = useOAuthStorage(session)
34-
35-
const atclient = new NodeOAuthClient({
36-
stateStore,
37-
sessionStore,
38-
clientMetadata,
39-
requestLock: getOAuthLock(),
40-
handleResolver,
41-
})
4229

4330
if (query.handle) {
4431
// Initiate auth flow
@@ -66,10 +53,13 @@ export default defineEventHandler(async event => {
6653
}
6754

6855
try {
69-
const redirectUrl = await atclient.authorize(query.handle, {
56+
const redirectUrl = await event.context.oauthClient.authorize(query.handle, {
7057
scope,
7158
prompt: query.create ? 'create' : undefined,
72-
ui_locales: query.locale?.toString(),
59+
// TODO: I do not beleive this is working as expected on
60+
// a unsupported locale on the PDS. Gives Invalid at body.ui_locales
61+
// Commenting out for now
62+
// ui_locales: query.locale?.toString(),
7363
state: encodeOAuthState(event, { redirectPath }),
7464
})
7565

@@ -87,7 +77,7 @@ export default defineEventHandler(async event => {
8777
// Handle callback
8878
try {
8979
const params = new URLSearchParams(query as Record<string, string>)
90-
const result = await atclient.callback(params)
80+
const result = await event.context.oauthClient.callback(params)
9181
try {
9282
const state = decodeOAuthState(event, result.state)
9383
const profile = await getMiniProfile(result.session)

server/api/auth/session.get.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession'
22
import { safeParse } from 'valibot'
33

4-
export default defineEventHandler(async event => {
5-
const serverSession = await useServerSession(event)
4+
export default eventHandlerWithOAuthSession(async (event, _, serverSession) => {
65
const result = safeParse(PublicUserSessionSchema, serverSession.data.public)
76
if (!result.success) {
87
return null
98
}
109

10+
// A one time redirect to upgrade the previous sessions.
11+
// Can remove in 2 weeks from merge if we'd like
12+
if (serverSession.data.oauthSession && serverSession.data?.public?.did) {
13+
await serverSession.update({
14+
oauthSession: undefined,
15+
})
16+
return {
17+
...result.output,
18+
relogin: true,
19+
}
20+
}
21+
1122
return result.output
1223
})

server/plugins/oauth-client.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { NodeOAuthClient } from '@atproto/oauth-client-node'
2+
3+
/**
4+
* Creates a long living instance of the NodeOAuthClient.
5+
*/
6+
export default defineNitroPlugin(async nitroApp => {
7+
const oauthClient = await getNodeOAuthClient()
8+
9+
// Attach to event context for access in composables via useRequestEvent()
10+
nitroApp.hooks.hook('request', event => {
11+
event.context.oauthClient = oauthClient
12+
})
13+
})
14+
15+
// Extend the H3EventContext type
16+
declare module 'h3' {
17+
interface H3EventContext {
18+
oauthClient: NodeOAuthClient
19+
}
20+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { loadJWKs } from '#server/utils/atproto/oauth'
2+
3+
export default defineEventHandler(async _ => {
4+
const keys = await loadJWKs()
5+
if (!keys) {
6+
console.error('Failed to load JWKs. May not be set')
7+
return []
8+
}
9+
10+
return keys.publicJwks
11+
})
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
export default defineEventHandler(() => {
2-
return getOauthClientMetadata()
1+
export default defineEventHandler(async _ => {
2+
const keyset = await loadJWKs()
3+
const pk = keyset?.findPrivateKey({ usage: 'sign' })
4+
return getOauthClientMetadata(pk?.alg)
35
})
Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
11
import type { NodeSavedSession, NodeSavedSessionStore } from '@atproto/oauth-client-node'
2-
import type { UserServerSession } from '#shared/types/userSession'
3-
import type { SessionManager } from 'h3'
2+
import { OAUTH_CACHE_STORAGE_BASE } from '#server/utils/atproto/storage'
3+
4+
// Refresh tokens from a confidential client should last for 180 days, each new refresh of access token resets
5+
// the expiration with the new refresh token. Shorting to 179 days to keep it a bit simpler since we rely on redis to clear sessions
6+
// Note: This expiration only lasts this long in production. Local dev is 2 weeks
7+
const SESSION_EXPIRATION = CACHE_MAX_AGE_ONE_DAY * 179
48

59
export class OAuthSessionStore implements NodeSavedSessionStore {
6-
private readonly session: SessionManager<UserServerSession>
10+
private readonly cache: CacheAdapter
11+
12+
constructor() {
13+
this.cache = getCacheAdapter(OAUTH_CACHE_STORAGE_BASE)
14+
}
715

8-
constructor(session: SessionManager<UserServerSession>) {
9-
this.session = session
16+
private createStorageKey(did: string) {
17+
return `sessions:${did}`
1018
}
1119

12-
async get(): Promise<NodeSavedSession | undefined> {
13-
const sessionData = this.session.data
14-
if (!sessionData) return undefined
15-
return sessionData.oauthSession
20+
async get(key: string): Promise<NodeSavedSession | undefined> {
21+
let session = await this.cache.get<NodeSavedSession>(this.createStorageKey(key))
22+
return session ?? undefined
1623
}
1724

18-
async set(_key: string, val: NodeSavedSession) {
19-
// We are ignoring the key since the mapping is already done in the session
20-
try {
21-
await this.session.update({
22-
oauthSession: val,
23-
})
24-
} catch (error) {
25-
// Not sure if this has been happening. But helps with debugging
26-
console.error(
27-
'[oauth session store] Failed to set session:',
28-
error instanceof Error ? error.message : 'Unknown error',
29-
)
30-
throw error
31-
}
25+
async set(key: string, val: NodeSavedSession) {
26+
await this.cache.set<NodeSavedSession>(this.createStorageKey(key), val, SESSION_EXPIRATION)
3227
}
3328

34-
async del() {
35-
await this.session.update({
36-
oauthSession: undefined,
37-
})
29+
async del(key: string) {
30+
await this.cache.delete(this.createStorageKey(key))
3831
}
3932
}

0 commit comments

Comments
 (0)