Skip to content

Commit

Permalink
OAuth: 2FA (bluesky-social#2633)
Browse files Browse the repository at this point in the history
* chore(ci): update setup-node & checkout actions to v4

* refactor(oauth): rename internal types to avoid conflicting types
fix(oauth): support building from parcel
feat(oauth): add runtime lock support to prevent concurrent session updates
feat(oauth): improve metadata validation
fix(oauth): allow use of handle as login hint
fix: proper parsing of authorization header
feat(oauth): add email 2fa support
feat(oauth): adapt auth UI to match app UI

* fix(oauth): improve parsing of digest algo

* fix(oauth-provider): dead code cleanup

* fix(oauth-provider): avoid inconsistent use of "id" prop in InputCheckbox

* style(oauth-provider): use if/else instead of switch

* feat(oauth-provider): stronger validation of customization data

Invalid oauth customization would cause the server to crash at startup.

* docs(oauth-client): explain why the abortRequest method is not mandatory

* fix(oauth-client): cancel fetch response body when not used

* docs: typo

Co-authored-by: devin ivy <[email protected]>

* feat(oauth-provider:metadata): add client_id_metadata_document_supported metadata

* fix(oauth-provider): require the content-type to be set on client metadata response

* feat(common): add obfuscation utilities
fix(pds): show user did in logs
fix(ozone): show user did in logs

* tidy

* fix(simple-store): avoid leaking context when calling hooks

* fix: use patch level changeset

* chore(oauth-types): add changeset regarding client_id_metadata_document_supported

* chore: add changeset for bsky & ozone

* unify loggerMiddleware instantiation

* tidy

---------

Co-authored-by: devin ivy <[email protected]>
  • Loading branch information
matthieusieben and devinivy authored Jul 12, 2024
1 parent b899505 commit acc9093
Show file tree
Hide file tree
Showing 132 changed files with 3,576 additions and 1,594 deletions.
5 changes: 5 additions & 0 deletions .changeset/blue-cats-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/oauth-types": patch
---

Add client_id_metadata_document_supported in metadata
6 changes: 6 additions & 0 deletions .changeset/eighty-bikes-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@atproto/jwk-jose": patch
"@atproto/jwk": patch
---

Allow build from Parcel
9 changes: 9 additions & 0 deletions .changeset/good-buses-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@atproto-labs/handle-resolver-node": patch
"@atproto-labs/identity-resolver": patch
"@atproto-labs/handle-resolver": patch
"@atproto-labs/did-resolver": patch
"@atproto-labs/simple-store": patch
---

Use distinct type names to prevent conflicts
5 changes: 5 additions & 0 deletions .changeset/hungry-peas-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---

Use new version of @atproto/oauth-provider with improved UI.
5 changes: 5 additions & 0 deletions .changeset/lazy-paws-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/oauth-client-node": patch
---

Create NodeJS OAuth SDK
8 changes: 8 additions & 0 deletions .changeset/nervous-ghosts-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@atproto/ozone": patch
"@atproto/bsync": patch
"@atproto/bsky": patch
"@atproto/pds": patch
---

Obfuscate request headers in logs using utils from @atproto/common
5 changes: 5 additions & 0 deletions .changeset/pretty-students-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": patch
---

Add 2FA support
7 changes: 7 additions & 0 deletions .changeset/slimy-oranges-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@atproto/oauth-client-browser": patch
"@atproto/oauth-client-node": patch
"@atproto/oauth-client": patch
---

Add event emitting capability to OAuthClient
5 changes: 5 additions & 0 deletions .changeset/slow-pigs-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---

Improve parsing of Authorization header
5 changes: 5 additions & 0 deletions .changeset/small-cups-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/dev-env": patch
---

Adapt to changes from @atproto/oauth-provider
5 changes: 5 additions & 0 deletions .changeset/tidy-months-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto-labs/simple-store": patch
---

Expose reason for deletion
5 changes: 5 additions & 0 deletions .changeset/young-ligers-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/common": patch
---

Add obfuscation utilities
17 changes: 10 additions & 7 deletions .github/workflows/repo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v3
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'pnpm'
Expand All @@ -39,11 +40,12 @@ jobs:
shard: [1/8, 2/8, 3/8, 4/8, 5/8, 6/8, 7/8, 8/8]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v3
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'pnpm'
Expand All @@ -58,15 +60,16 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v3
run_install: false
- uses: actions/setup-node@v4
with:
node-version: 18
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm i --frozen-lockfile
- uses: actions/download-artifact@v4
with:
name: dist
Expand Down
92 changes: 11 additions & 81 deletions packages/bsky/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IncomingMessage } from 'node:http'
import { stdSerializers } from 'pino'
import pinoHttp from 'pino-http'
import { subsystemLogger } from '@atproto/common'
import { obfuscateHeaders, subsystemLogger } from '@atproto/common'

export const dbLogger: ReturnType<typeof subsystemLogger> =
subsystemLogger('bsky:db')
Expand All @@ -20,85 +21,14 @@ export const httpLogger: ReturnType<typeof subsystemLogger> =
export const loggerMiddleware = pinoHttp({
logger: httpLogger,
serializers: {
err: errSerializer,
req: reqSerializer,
err: (err: unknown) => ({
code: err?.['code'],
message: err?.['message'],
}),
req: (req: IncomingMessage) => {
const serialized = stdSerializers.req(req)
const headers = obfuscateHeaders(serialized.headers)
return { ...serialized, headers }
},
},
})

function errSerializer(err: any) {
return {
code: err?.code,
message: err?.message,
}
}

function reqSerializer(req: any) {
const serialized = stdSerializers.req(req)
serialized.headers = obfuscateHeaders(serialized.headers)
return serialized
}

function obfuscateHeaders(headers: Record<string, string>) {
const obfuscatedHeaders: Record<string, string> = {}
for (const key in headers) {
if (key.toLowerCase() === 'authorization') {
obfuscatedHeaders[key] = obfuscateAuthHeader(headers[key])
} else if (key.toLowerCase() === 'dpop') {
obfuscatedHeaders[key] = obfuscateJws(headers[key]) || 'Invalid'
} else {
obfuscatedHeaders[key] = headers[key]
}
}
return obfuscatedHeaders
}

function obfuscateAuthHeader(authHeader: string): string {
// This is a hot path (runs on every request). Avoid using split() or regex.

const spaceIdx = authHeader.indexOf(' ')
if (spaceIdx === -1) return 'Invalid'

const type = authHeader.slice(0, spaceIdx)
switch (type.toLowerCase()) {
case 'bearer':
return `${type} ${obfuscateBearer(authHeader.slice(spaceIdx + 1))}`
case 'dpop':
return `${type} ${obfuscateJws(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
case 'basic':
return `${type} ${obfuscateBasic(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
default:
return `Invalid`
}
}

function obfuscateBasic(token: string): null | string {
if (!token) return null
const buffer = Buffer.from(token, 'base64')
if (!buffer.length) return null // Buffer.from will silently ignore invalid base64 chars
const authHeader = buffer.toString('utf8')
const colIdx = authHeader.indexOf(':')
if (colIdx === -1) return null
const username = authHeader.slice(0, colIdx)
return `${username}:***`
}

function obfuscateBearer(token: string): string {
return obfuscateJws(token) || obfuscateToken(token)
}

function obfuscateToken(token: string): string {
return token ? '***' : ''
}

function obfuscateJws(token: string): null | string {
const firstDot = token.indexOf('.')
if (firstDot === -1) return null

const secondDot = token.indexOf('.', firstDot + 1)
if (secondDot === -1) return null

if (token.indexOf('.', secondDot + 1) !== -1) return null

// Strip the signature
return token.slice(0, secondDot) + '.obfuscated'
}
18 changes: 11 additions & 7 deletions packages/bsync/src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pinoHttp from 'pino-http'
import { subsystemLogger } from '@atproto/common'
import { type IncomingMessage } from 'node:http'
import pinoHttp, { stdSerializers } from 'pino-http'
import { obfuscateHeaders, subsystemLogger } from '@atproto/common'

export const dbLogger: ReturnType<typeof subsystemLogger> =
subsystemLogger('bsync:db')
Expand All @@ -12,11 +13,14 @@ export const loggerMiddleware = pinoHttp({
paths: ['req.headers.authorization'],
},
serializers: {
err: (err) => {
return {
code: err?.code,
message: err?.message,
}
err: (err: unknown) => ({
code: err?.['code'],
message: err?.['message'],
}),
req: (req: IncomingMessage) => {
const serialized = stdSerializers.req(req)
const headers = obfuscateHeaders(serialized.headers)
return { ...serialized, headers }
},
},
})
3 changes: 2 additions & 1 deletion packages/common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
export * from '@atproto/common-web'
export * from './buffers'
export * from './dates'
export * from './env'
export * from './fs'
export * from './ipld'
export * from './ipld-multi'
export * from './logger'
export * from './obfuscate'
export * from './streams'
export * from './buffers'
85 changes: 85 additions & 0 deletions packages/common/src/obfuscate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export function obfuscateEmail(email: string) {
const [local, domain] = email.split('@')
return `${obfuscateWord(local)}@${obfuscateWord(domain)}`
}

export function obfuscateWord(word: string) {
return `${word.charAt(0)}***${word.charAt(word.length - 1)}`
}

export function obfuscateHeaders(headers: Record<string, string>) {
const obfuscatedHeaders: Record<string, string> = {}
for (const key in headers) {
if (key.toLowerCase() === 'authorization') {
obfuscatedHeaders[key] = obfuscateAuthHeader(headers[key])
} else if (key.toLowerCase() === 'dpop') {
obfuscatedHeaders[key] = obfuscateJwt(headers[key]) || 'Invalid'
} else {
obfuscatedHeaders[key] = headers[key]
}
}
return obfuscatedHeaders
}

export function obfuscateAuthHeader(authHeader: string): string {
// This is a hot path (runs on every request). Avoid using split() or regex.

const spaceIdx = authHeader.indexOf(' ')
if (spaceIdx === -1) return 'Invalid'

const type = authHeader.slice(0, spaceIdx)
switch (type.toLowerCase()) {
case 'bearer':
case 'dpop':
return `${type} ${obfuscateBearer(authHeader.slice(spaceIdx + 1))}`
case 'basic':
return `${type} ${obfuscateBasic(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
default:
return `Invalid`
}
}

export function obfuscateBasic(token: string): null | string {
if (!token) return null
const buffer = Buffer.from(token, 'base64')
if (!buffer.length) return null // Buffer.from will silently ignore invalid base64 chars
const authHeader = buffer.toString('utf8')
const colIdx = authHeader.indexOf(':')
if (colIdx === -1) return null
const username = authHeader.slice(0, colIdx)
return `${username}:***`
}

export function obfuscateBearer(token: string): string {
return obfuscateJwt(token) || obfuscateToken(token)
}

export function obfuscateToken(token: string): string {
if (token.length >= 12) return obfuscateWord(token)
return token ? '***' : ''
}

export function obfuscateJwt(token: string): null | string {
const firstDot = token.indexOf('.')
if (firstDot === -1) return null

const secondDot = token.indexOf('.', firstDot + 1)
if (secondDot === -1) return null

// Expected to be missing
const thirdDot = token.indexOf('.', secondDot + 1)
if (thirdDot !== -1) return null

try {
const payloadEnc = token.slice(firstDot + 1, secondDot)
const payloadJson = Buffer.from(payloadEnc, 'base64').toString('utf8')
const payload = JSON.parse(payloadJson)
if (typeof payload.sub === 'string') return payload.sub
} catch {
// Invalid JWT
return null
}

// Strip the signature
return token.slice(0, secondDot) + '.obfuscated'
}
2 changes: 1 addition & 1 deletion packages/dev-env/src/pds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class TestPds {
inviteRequired: false,
fetchDisableSsrfProtection: true,
serviceName: 'Development PDS',
primaryColor: '#ffcb1e',
brandColor: '#ffcb1e',
errorColor: undefined,
logoUrl:
'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png',
Expand Down
6 changes: 3 additions & 3 deletions packages/internal/did-resolver/src/did-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { CachedGetter, SimpleStore } from '@atproto-labs/simple-store'
import { Did, DidDocument } from '@atproto/did'

import { DidCacheMemory } from './did-cache-memory.js'
import { DidMethod, ResolveOptions } from './did-method.js'
import { DidMethod, ResolveDidOptions } from './did-method.js'
import { DidResolver, ResolvedDocument } from './did-resolver.js'

export type { DidMethod, ResolveOptions, ResolvedDocument }
export type { DidMethod, ResolvedDocument, ResolveDidOptions }

export type DidCache = SimpleStore<Did, DidDocument>

Expand All @@ -25,7 +25,7 @@ export class DidResolverCached<M extends string = string>
)
}

public async resolve<D extends Did>(did: D, options?: ResolveOptions) {
public async resolve<D extends Did>(did: D, options?: ResolveDidOptions) {
return this.getter.get(did, options) as Promise<ResolvedDocument<D, M>>
}
}
Loading

0 comments on commit acc9093

Please sign in to comment.