Skip to content

Commit

Permalink
fixup! add oauth provider capability to PDS
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed Feb 29, 2024
1 parent 06a6941 commit d8d6afa
Show file tree
Hide file tree
Showing 14 changed files with 346 additions and 252 deletions.
3 changes: 2 additions & 1 deletion packages/pds/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX="3ee68..."
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX="e049f..."

# Secrets - update to secure high-entropy strings
PDS_DPOP_SECRET="32-random-bytes-hex-encoded"
PDS_JWT_SECRET="jwt-secret"
PDS_ADMIN_PASSWORD="admin-pass"

# Environment - example is for sandbox
PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev"
PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev"
PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev"
PDS_CRAWLERS="https://bgs.bsky-sandbox.dev"
PDS_CRAWLERS="https://bgs.bsky-sandbox.dev"
11 changes: 6 additions & 5 deletions packages/pds/src/account-manager/helpers/used-refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ export const insert = async (
id: number,
usedRefreshToken: RefreshToken,
) => {
await db.db
.insertInto('used_refresh_token')
.values({ id, usedRefreshToken })
.onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing())
.execute()
await db.executeWithRetry(
db.db
.insertInto('used_refresh_token')
.values({ id, usedRefreshToken })
.onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing()),
)
}

export const findByToken = (db: AccountDb, usedRefreshToken: RefreshToken) => {
Expand Down
1 change: 0 additions & 1 deletion packages/pds/src/account-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export class AccountManager
dbLocation: string,
private jwtKey: KeyObject,
private serviceDid: string,
readonly publicUrl: string,
disableWalAutoCheckpoint = false,
) {
this.db = getDb(dbLocation, disableWalAutoCheckpoint)
Expand Down
20 changes: 18 additions & 2 deletions packages/pds/src/api/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Headers } from '@atproto/xrpc'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { IncomingMessage } from 'node:http'

export const resultPassthru = <T>(result: { headers: Headers; data: T }) => {
Expand All @@ -24,9 +25,24 @@ export function authPassthru(
| undefined

export function authPassthru(req: IncomingMessage, withEncoding?: boolean) {
if (req.headers.authorization) {
const { authorization } = req.headers

if (authorization) {
// DPoP requests are bound to the endpoint being called. Allowing them to be
// proxied would require that the receiving end allows DPoP proof not
// created for him. Since proxying is mainly there to support legacy
// clients, and DPoP is a new feature, we don't support DPoP requests
// through the proxy.

// This is fine since app views are usually called using the requester's
// credentials when "auth.credentials.type === 'access'", which is the only
// case were DPoP is used.
if (authorization.startsWith('DPoP ') || req.headers['dpop']) {
throw new InvalidRequestError('DPoP requests cannot be proxied')
}

return {
headers: { authorization: req.headers.authorization },
headers: { authorization },
encoding: withEncoding ? 'application/json' : undefined,
}
}
Expand Down
90 changes: 85 additions & 5 deletions packages/pds/src/auth-verifier.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto'
import { IdResolver } from '@atproto/identity'
import { Keyset } from '@atproto/jwk'
import { DpopProvider } from '@atproto/oauth-provider'
import {
AuthRequiredError,
ForbiddenError,
InvalidRequestError,
verifyJwt as verifyServiceJwt,
} from '@atproto/xrpc-server'
import { IdResolver } from '@atproto/identity'
import * as ui8 from 'uint8arrays'
import express from 'express'
import * as jose from 'jose'
import KeyEncoder from 'key-encoder'
import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto'
import * as ui8 from 'uint8arrays'
import { AccountManager } from './account-manager'
import { softDeleted } from './db'

Expand Down Expand Up @@ -94,6 +96,9 @@ type ValidatedRefreshBearer = ValidatedBearer & {
}

export type AuthVerifierOpts = {
dpopProvider: DpopProvider
issuer: string
keyset: Keyset
jwtKey: KeyObject
adminPass: string
moderatorPass: string
Expand All @@ -106,6 +111,9 @@ export type AuthVerifierOpts = {
}

export class AuthVerifier {
private _dpopProvider: DpopProvider
private _issuer: string
private _keyset: Keyset
private _jwtKey: KeyObject
private _adminPass: string
private _moderatorPass: string
Expand All @@ -117,6 +125,9 @@ export class AuthVerifier {
public idResolver: IdResolver,
opts: AuthVerifierOpts,
) {
this._dpopProvider = opts.dpopProvider
this._issuer = opts.issuer
this._keyset = opts.keyset
this._jwtKey = opts.jwtKey
this._adminPass = opts.adminPass
this._moderatorPass = opts.moderatorPass
Expand Down Expand Up @@ -321,7 +332,15 @@ export class AuthVerifier {
throw new AuthRequiredError(undefined, 'AuthMissing')
}

const { payload } = await this.jwtVerify(token, verifyOptions)
const { payload, protectedHeader } = await this.jwtVerify(
token,
verifyOptions,
)

if (protectedHeader.typ) {
// Only OAuth Provider sets this claim
throw new InvalidRequestError('Malformed token', 'InvalidToken')
}

const { sub, aud, scope } = payload
if (typeof sub !== 'string' || !sub.startsWith('did:')) {
Expand Down Expand Up @@ -357,6 +376,8 @@ export class AuthVerifier {
switch (type) {
case BEARER:
return this.validateBearerAccessToken(req, scopes)
case DPOP:
return this.validateDpopAccessToken(req, scopes)
case null:
throw new AuthRequiredError(undefined, 'AuthMissing')
default:
Expand All @@ -367,6 +388,64 @@ export class AuthVerifier {
}
}

async validateDpopAccessToken(
req: express.Request,
scopes: AuthScope[],
): Promise<AccessOutput> {
const [tokenType, token] = parseAuthorizationHeader(
req.headers.authorization,
)
if (tokenType !== DPOP) {
throw new InvalidRequestError(
'Unexpected authorization type',
'InvalidToken',
)
}

const url = new URL(req.url, this._issuer)

const dpopJkt = await this._dpopProvider.dpopCheck(
req.headers.dpop,
req.method,
url.href,
token,
)

if (!dpopJkt) {
throw new InvalidRequestError('DPop proof required', 'InvalidToken')
}

const { payload } = await this._keyset.verify<{
aud: string
sub: `did:${string}`
}>(token as any, {
requiredClaims: ['aud', 'sub', 'iss', 'client_id'],
issuer: this._issuer,
audience: this.dids.pds,
typ: 'at+jwt',
})

const { sub } = payload
if (typeof sub !== 'string' || !sub.startsWith('did:')) {
throw new InvalidRequestError('Malformed token', 'InvalidToken')
}

if (!dpopJkt || (payload.cnf as any)?.jkt !== dpopJkt) {
// TODO add a specific error code
throw new InvalidRequestError('Invalid DPop key', 'InvalidToken')
}

return {
credentials: {
type: 'access',
did: sub,
scope: AuthScope.Access,
audience: payload.aud,
},
artifacts: token,
}
}

async validateBearerAccessToken(
req: express.Request,
scopes: AuthScope[],
Expand Down Expand Up @@ -464,6 +543,7 @@ export class AuthVerifier {

const BASIC = 'Basic'
const BEARER = 'Bearer'
const DPOP = 'DPoP'

export const parseAuthorizationHeader = (authorization?: string) => {
const result = authorization?.split(' ', 2)
Expand All @@ -473,7 +553,7 @@ export const parseAuthorizationHeader = (authorization?: string) => {

const isAccessToken = (req: express.Request): boolean => {
const [type] = parseAuthorizationHeader(req.headers.authorization)
return type === BEARER
return type === BEARER || type === DPOP
}

const isBearerToken = (req: express.Request): boolean => {
Expand Down
12 changes: 12 additions & 0 deletions packages/pds/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => {

const crawlersCfg: ServerConfig['crawlers'] = env.crawlers ?? []

const oauthProviderCfg: ServerConfig['oauthProvider'] = entrywayCfg
? null
: {
unsafeFetch: !!env.oauthUnsafeFetch,
}

return {
service: serviceCfg,
db: dbCfg,
Expand All @@ -248,6 +254,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => {
redis: redisCfg,
rateLimits: rateLimitsCfg,
crawlers: crawlersCfg,
oauthProvider: oauthProviderCfg,
}
}

Expand All @@ -268,6 +275,7 @@ export type ServerConfig = {
redis: RedisScratchConfig | null
rateLimits: RateLimitsConfig
crawlers: string[]
oauthProvider: OAuthProviderConfig | null
}

export type ServiceConfig = {
Expand Down Expand Up @@ -331,6 +339,10 @@ export type EntrywayConfig = {
plcRotationKey: string
}

export type OAuthProviderConfig = {
unsafeFetch: boolean
}

export type InvitesConfig =
| {
required: true
Expand Down
8 changes: 8 additions & 0 deletions packages/pds/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const readEnv = (): ServerEnvironment => {
crawlers: envList('PDS_CRAWLERS'),

// secrets
dpopSecret: envStr('PDS_DPOP_SECRET'),
jwtSecret: envStr('PDS_JWT_SECRET'),
adminPassword: envStr('PDS_ADMIN_PASSWORD'),
moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'),
Expand All @@ -105,6 +106,9 @@ export const readEnv = (): ServerEnvironment => {
plcRotationKeyK256PrivateKeyHex: envStr(
'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX',
),

// oauth
oauthUnsafeFetch: envBool('PDS_OAUTH_UNSAFE_FETCH') ?? false,
}
}

Expand Down Expand Up @@ -199,6 +203,7 @@ export type ServerEnvironment = {
crawlers?: string[]

// secrets
dpopSecret?: string
jwtSecret?: string
adminPassword?: string
moderatorPassword?: string
Expand All @@ -207,4 +212,7 @@ export type ServerEnvironment = {
// keys
plcRotationKeyKmsKeyId?: string
plcRotationKeyK256PrivateKeyHex?: string

// oauth
oauthUnsafeFetch: boolean
}
6 changes: 6 additions & 0 deletions packages/pds/src/config/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => {
throw new Error('Must configure plc rotation key')
}

if (!env.dpopSecret) {
throw new Error('Must provide a DPoP secret')
}

if (!env.jwtSecret) {
throw new Error('Must provide a JWT secret')
}
Expand All @@ -27,6 +31,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => {
}

return {
dpopSecret: env.dpopSecret,
jwtSecret: env.jwtSecret,
adminPassword: env.adminPassword,
moderatorPassword: env.moderatorPassword ?? env.adminPassword,
Expand All @@ -37,6 +42,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => {
}

export type ServerSecrets = {
dpopSecret: string
jwtSecret: string
adminPassword: string
moderatorPassword: string
Expand Down
Loading

0 comments on commit d8d6afa

Please sign in to comment.