Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix auth scopes for account migration #3273

Merged
merged 15 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-meals-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---

Allow takendown account scope on access tokens. Allow takendown accounts to createReports at discretion of the moderation service
5 changes: 5 additions & 0 deletions .changeset/large-laws-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/api": patch
---

Allow createSession to request takendown account scope
5 changes: 5 additions & 0 deletions .changeset/thick-shrimps-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---

Allow takendown accounts to perform account migration
6 changes: 5 additions & 1 deletion lexicons/com/atproto/server/createSession.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"description": "Handle or other identifier supported by the server for the authenticating user."
},
"password": { "type": "string" },
"authFactorToken": { "type": "string" }
"authFactorToken": { "type": "string" },
"allowTakendown": {
"type": "boolean",
"description": "When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned"
}
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
5 changes: 5 additions & 0 deletions packages/bsky/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
43 changes: 42 additions & 1 deletion packages/ozone/src/api/report/createReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { Server } from '../../lexicon'
import AppContext from '../../context'
import { getReasonType } from '../util'
import { subjectFromInput } from '../../mod-service/subject'
import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
import {
REASONAPPEAL,
ReasonType,
} from '../../lexicon/types/com/atproto/moderation/defs'
import { ForbiddenError } from '@atproto/xrpc-server'
import { TagService } from '../../tag-service'
import { ModerationService } from '../../mod-service'
import { getTagForReport } from '../../tag-service/util'

export default function (server: Server, ctx: AppContext) {
Expand All @@ -22,6 +26,9 @@ export default function (server: Server, ctx: AppContext) {
}

const db = ctx.db

await assertValidReporter(ctx.modService(db), reasonType, requester)

const report = await db.transaction(async (dbTxn) => {
const moderationTxn = ctx.modService(dbTxn)
const { event: reportEvent, subjectStatus } =
Expand Down Expand Up @@ -51,3 +58,37 @@ export default function (server: Server, ctx: AppContext) {
},
})
}

const assertValidReporter = async (
modService: ModerationService,
reasonType: ReasonType,
did: string,
) => {
const reporterStatus = await modService.getCurrentStatus({ did })

// If we don't have a mod status for the reporter, no need to do further checks
if (!reporterStatus.length) {
return
}

// For appeals, we just need to make sure that the account does not have pending appeal
if (reasonType === REASONAPPEAL) {
if (reporterStatus[0]?.appealed) {
throw new ForbiddenError(
'Awaiting decision on previous appeal',
'AlreadyAppealed',
)
}
return
}

// For non appeals, we need to make sure the reporter account is not already in takendown status
// This is necessary because we allow takendown accounts call createReport but that's only meant for appeals
// and we need to make sure takendown accounts don't abuse this endpoint
if (reporterStatus[0]?.takendown) {
throw new ForbiddenError(
'Report not accepted from takendown account',
'AccountTakedown',
)
}
}
5 changes: 5 additions & 0 deletions packages/ozone/src/lexicon/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,11 @@ export const schemaDict = {
authFactorToken: {
type: 'string',
},
allowTakendown: {
type: 'boolean',
description:
'When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned',
},
},
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface InputSchema {
identifier: string
password: string
authFactorToken?: string
/** When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned */
allowTakendown?: boolean
[k: string]: unknown
}

Expand Down
6 changes: 5 additions & 1 deletion packages/pds/src/account-manager/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,11 @@ export const getRefreshTokenId = () => {
return ui8.toString(crypto.randomBytes(32), 'base64')
}

export const formatScope = (appPassword: AppPassDescript | null): AuthScope => {
export const formatScope = (
appPassword: AppPassDescript | null,
isSoftDeleted?: boolean,
): AuthScope => {
if (isSoftDeleted) return AuthScope.Takendown
if (!appPassword) return AuthScope.Access
return appPassword.privileged
? AuthScope.AppPassPrivileged
Expand Down
25 changes: 14 additions & 11 deletions packages/pds/src/account-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,15 +211,19 @@ export class AccountManager
async createSession(
did: string,
appPassword: password.AppPassDescript | null,
isSoftDeleted = false,
) {
const { accessJwt, refreshJwt } = await auth.createTokens({
did,
jwtKey: this.jwtKey,
serviceDid: this.serviceDid,
scope: auth.formatScope(appPassword),
scope: auth.formatScope(appPassword, isSoftDeleted),
})
const refreshPayload = auth.decodeRefreshToken(refreshJwt)
await auth.storeRefreshToken(this.db, refreshPayload, appPassword)
// For soft deleted accounts don't store refresh token so that it can't be rotated.
if (!isSoftDeleted) {
const refreshPayload = auth.decodeRefreshToken(refreshJwt)
await auth.storeRefreshToken(this.db, refreshPayload, appPassword)
}
return { accessJwt, refreshJwt }
}

Expand Down Expand Up @@ -295,6 +299,7 @@ export class AccountManager
}): Promise<{
user: ActorAccount
appPassword: password.AppPassDescript | null
isSoftDeleted: boolean
}> {
const start = Date.now()
try {
Expand All @@ -313,27 +318,25 @@ export class AccountManager
if (!user) {
throw new AuthRequiredError('Invalid identifier or password')
}
const isSoftDeleted = softDeleted(user)

let appPassword: password.AppPassDescript | null = null
const validAccountPass = await this.verifyAccountPassword(
user.did,
password,
)
if (!validAccountPass) {
// takendown/suspended accounts cannot login with app password
if (isSoftDeleted) {
throw new AuthRequiredError('Invalid identifier or password')
}
appPassword = await this.verifyAppPassword(user.did, password)
if (appPassword === null) {
throw new AuthRequiredError('Invalid identifier or password')
}
}

if (softDeleted(user)) {
throw new AuthRequiredError(
'Account has been taken down',
'AccountTakedown',
)
}

return { user, appPassword }
return { user, appPassword, isSoftDeleted }
} finally {
// Mitigate timing attacks
await wait(350 - (Date.now() - start))
Expand Down
5 changes: 4 additions & 1 deletion packages/pds/src/api/app/bsky/actor/getPreferences.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { AuthScope } from '../../../../auth-verifier'

export default function (server: Server, ctx: AppContext) {
if (!ctx.cfg.bskyAppView) return
server.app.bsky.actor.getPreferences({
auth: ctx.authVerifier.accessStandard(),
auth: ctx.authVerifier.accessStandard({
additional: [AuthScope.Takendown],
}),
handler: async ({ auth }) => {
const requester = auth.credentials.did
const preferences = await ctx.actorStore.read(requester, (store) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { InvalidRequestError } from '@atproto/xrpc-server'
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import { ids } from '../../../../lexicon/lexicons'
import { AuthScope } from '../../../../auth-verifier'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.identity.requestPlcOperationSignature({
auth: ctx.authVerifier.accessFull(),
auth: ctx.authVerifier.accessFull({ additional: [AuthScope.Takendown] }),
handler: async ({ auth }) => {
if (ctx.entrywayAgent) {
assert(ctx.cfg.entryway)
Expand Down
2 changes: 2 additions & 0 deletions packages/pds/src/api/com/atproto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AppContext from '../../../context'
import { Server } from '../../../lexicon'
import admin from './admin'
import identity from './identity'
import moderation from './moderation'
import repo from './repo'
import serverMethods from './server'
import sync from './sync'
Expand All @@ -10,6 +11,7 @@ import temp from './temp'
export default function (server: Server, ctx: AppContext) {
admin(server, ctx)
identity(server, ctx)
moderation(server, ctx)
repo(server, ctx)
serverMethods(server, ctx)
sync(server, ctx)
Expand Down
36 changes: 36 additions & 0 deletions packages/pds/src/api/com/atproto/moderation/createReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Server } from '../../../../lexicon'
import AppContext from '../../../../context'
import { parseProxyInfo } from '../../../../pipethrough'
import { ids } from '../../../../lexicon/lexicons'
import { AtpAgent } from '@atproto/api'
import { AuthScope } from '../../../../auth-verifier'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.moderation.createReport({
auth: ctx.authVerifier.accessStandard({
additional: [AuthScope.Takendown],
}),
handler: async ({ auth, input, req }) => {
const { url, did: aud } = await parseProxyInfo(
ctx,
req,
ids.ComAtprotoModerationCreateReport,
)
const agent = new AtpAgent({ service: url })
const serviceAuth = await ctx.serviceAuthHeaders(
auth.credentials.did,
aud,
ids.ComAtprotoModerationCreateReport,
)
const res = await agent.com.atproto.moderation.createReport(input.body, {
...serviceAuth,
encoding: 'application/json',
})

return {
encoding: 'application/json',
body: res.data,
}
},
})
}
7 changes: 7 additions & 0 deletions packages/pds/src/api/com/atproto/moderation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import createReport from './createReport'

export default function (server: Server, ctx: AppContext) {
createReport(server, ctx)
}
13 changes: 11 additions & 2 deletions packages/pds/src/api/com/atproto/server/createSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DAY, MINUTE } from '@atproto/common'
import { INVALID_HANDLE } from '@atproto/syntax'
import { AuthRequiredError } from '@atproto/xrpc-server'

import { formatAccountStatus } from '../../../../account-manager'
import AppContext from '../../../../context'
Expand Down Expand Up @@ -31,10 +32,18 @@ export default function (server: Server, ctx: AppContext) {
)
}

const { user, appPassword } = await ctx.accountManager.login(input.body)
const { user, isSoftDeleted, appPassword } =
await ctx.accountManager.login(input.body)

if (!input.body.allowTakendown && isSoftDeleted) {
throw new AuthRequiredError(
'Account has been taken down',
'AccountTakedown',
)
}

const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([
ctx.accountManager.createSession(user.did, appPassword),
ctx.accountManager.createSession(user.did, appPassword, isSoftDeleted),
didDocForSession(ctx, user.did),
])

Expand Down
3 changes: 2 additions & 1 deletion packages/pds/src/api/com/atproto/server/deactivateAccount.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AuthScope } from '../../../../auth-verifier'
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import { authPassthru } from '../../../proxy'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.server.deactivateAccount({
auth: ctx.authVerifier.accessFull(),
auth: ctx.authVerifier.accessFull({ additional: [AuthScope.Takendown] }),
handler: async ({ req, auth, input }) => {
// in the case of entryway, the full flow is deactivateAccount (PDS) -> deactivateAccount (Entryway) -> updateSubjectStatus(PDS)
if (ctx.entrywayAgent) {
Expand Down
15 changes: 14 additions & 1 deletion packages/pds/src/api/com/atproto/server/getServiceAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ import { HOUR, MINUTE } from '@atproto/common'
import AppContext from '../../../../context'
import { Server } from '../../../../lexicon'
import { PRIVILEGED_METHODS, PROTECTED_METHODS } from '../../../../pipethrough'
import { AuthScope } from '../../../../auth-verifier'
import { ids } from '../../../../lexicon/lexicons'

export default function (server: Server, ctx: AppContext) {
server.com.atproto.server.getServiceAuth({
auth: ctx.authVerifier.accessStandard(),
auth: ctx.authVerifier.accessStandard({
additional: [AuthScope.Takendown],
}),
handler: async ({ params, auth }) => {
const did = auth.credentials.did
const { aud, lxm = null } = params
const exp = params.exp ? params.exp * 1000 : undefined

// Takendown accounts should not be able to generate service auth tokens except for methods necessary for account migration
if (
auth.credentials.scope === AuthScope.Takendown &&
lxm !== ids.ComAtprotoServerCreateAccount
) {
throw new InvalidRequestError('Bad token scope', 'InvalidToken')
}

if (exp) {
const diff = exp - Date.now()
if (diff < 0) {
Expand Down
Loading
Loading