diff --git a/lexicons/com/atproto/server/createAppPassword.json b/lexicons/com/atproto/server/createAppPassword.json new file mode 100644 index 00000000000..ae7810e7e0e --- /dev/null +++ b/lexicons/com/atproto/server/createAppPassword.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.createAppPassword", + "defs": { + "main": { + "type": "procedure", + "description": "Create an app-specific password.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"} + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#appPassword" + } + }, + "errors": [ + {"name": "AccountTakedown"} + ] + }, + "appPassword": { + "type": "object", + "required": ["name", "password", "createdAt"], + "properties": { + "name": {"type": "string"}, + "password": {"type": "string"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } +} diff --git a/lexicons/com/atproto/server/listAppPasswords.json b/lexicons/com/atproto/server/listAppPasswords.json new file mode 100644 index 00000000000..613ca17ae43 --- /dev/null +++ b/lexicons/com/atproto/server/listAppPasswords.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.listAppPasswords", + "defs": { + "main": { + "type": "query", + "description": "List all app-specific passwords.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["passwords"], + "properties": { + "passwords": { + "type": "array", + "items": {"type": "ref", "ref": "#appPassword"} + } + } + } + }, + "errors": [ + {"name": "AccountTakedown"} + ] + }, + "appPassword": { + "type": "object", + "required": ["name", "createdAt"], + "properties": { + "name": {"type": "string"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } +} diff --git a/lexicons/com/atproto/server/revokeAppPassword.json b/lexicons/com/atproto/server/revokeAppPassword.json new file mode 100644 index 00000000000..265f89544e7 --- /dev/null +++ b/lexicons/com/atproto/server/revokeAppPassword.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.revokeAppPassword", + "defs": { + "main": { + "type": "procedure", + "description": "Revoke an app-specific password by name.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"} + } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 84194368131..cab80a00643 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -39,6 +39,7 @@ import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' +import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' import * as ComAtprotoServerCreateInviteCodes from './types/com/atproto/server/createInviteCodes' import * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' @@ -48,10 +49,12 @@ import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/delet import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' +import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -128,6 +131,7 @@ export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' export * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' +export * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' export * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' export * as ComAtprotoServerCreateInviteCodes from './types/com/atproto/server/createInviteCodes' export * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' @@ -137,10 +141,12 @@ export * as ComAtprotoServerDeleteSession from './types/com/atproto/server/delet export * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' export * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +export * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' export * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' +export * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' export * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' export * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' export * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -605,6 +611,17 @@ export class ServerNS { }) } + createAppPassword( + data?: ComAtprotoServerCreateAppPassword.InputSchema, + opts?: ComAtprotoServerCreateAppPassword.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.createAppPassword', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerCreateAppPassword.toKnownErr(e) + }) + } + createInviteCode( data?: ComAtprotoServerCreateInviteCode.InputSchema, opts?: ComAtprotoServerCreateInviteCode.CallOptions, @@ -693,6 +710,17 @@ export class ServerNS { }) } + listAppPasswords( + params?: ComAtprotoServerListAppPasswords.QueryParams, + opts?: ComAtprotoServerListAppPasswords.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.listAppPasswords', params, undefined, opts) + .catch((e) => { + throw ComAtprotoServerListAppPasswords.toKnownErr(e) + }) + } + refreshSession( data?: ComAtprotoServerRefreshSession.InputSchema, opts?: ComAtprotoServerRefreshSession.CallOptions, @@ -736,6 +764,17 @@ export class ServerNS { throw ComAtprotoServerResetPassword.toKnownErr(e) }) } + + revokeAppPassword( + data?: ComAtprotoServerRevokeAppPassword.InputSchema, + opts?: ComAtprotoServerRevokeAppPassword.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.revokeAppPassword', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerRevokeAppPassword.toKnownErr(e) + }) + } } export class SyncNS { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 76c912cfa6e..25dd2a3651f 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2075,6 +2075,56 @@ export const schemaDict = { }, }, }, + ComAtprotoServerCreateAppPassword: { + lexicon: 1, + id: 'com.atproto.server.createAppPassword', + defs: { + main: { + type: 'procedure', + description: 'Create an app-specific password.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.server.createAppPassword#appPassword', + }, + }, + errors: [ + { + name: 'AccountTakedown', + }, + ], + }, + appPassword: { + type: 'object', + required: ['name', 'password', 'createdAt'], + properties: { + name: { + type: 'string', + }, + password: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerCreateInviteCode: { lexicon: 1, id: 'com.atproto.server.createInviteCode', @@ -2441,6 +2491,50 @@ export const schemaDict = { }, }, }, + ComAtprotoServerListAppPasswords: { + lexicon: 1, + id: 'com.atproto.server.listAppPasswords', + defs: { + main: { + type: 'query', + description: 'List all app-specific passwords.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['passwords'], + properties: { + passwords: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.listAppPasswords#appPassword', + }, + }, + }, + }, + }, + errors: [ + { + name: 'AccountTakedown', + }, + ], + }, + appPassword: { + type: 'object', + required: ['name', 'createdAt'], + properties: { + name: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -2544,6 +2638,28 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRevokeAppPassword: { + lexicon: 1, + id: 'com.atproto.server.revokeAppPassword', + defs: { + main: { + type: 'procedure', + description: 'Revoke an app-specific password by name.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -4842,6 +4958,7 @@ export const ids = { ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', + ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', ComAtprotoServerCreateInviteCodes: 'com.atproto.server.createInviteCodes', ComAtprotoServerCreateSession: 'com.atproto.server.createSession', @@ -4852,12 +4969,14 @@ export const ids = { ComAtprotoServerGetAccountInviteCodes: 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerGetSession: 'com.atproto.server.getSession', + ComAtprotoServerListAppPasswords: 'com.atproto.server.listAppPasswords', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', + ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/api/src/client/types/com/atproto/server/createAppPassword.ts b/packages/api/src/client/types/com/atproto/server/createAppPassword.ts new file mode 100644 index 00000000000..2dc001146b0 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/createAppPassword.ts @@ -0,0 +1,64 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + name: string + [k: string]: unknown +} + +export type OutputSchema = AppPassword + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export class AccountTakedownError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'AccountTakedown') return new AccountTakedownError(e) + } + return e +} + +export interface AppPassword { + name: string + password: string + createdAt: string + [k: string]: unknown +} + +export function isAppPassword(v: unknown): v is AppPassword { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.createAppPassword#appPassword' + ) +} + +export function validateAppPassword(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.createAppPassword#appPassword', + v, + ) +} diff --git a/packages/api/src/client/types/com/atproto/server/listAppPasswords.ts b/packages/api/src/client/types/com/atproto/server/listAppPasswords.ts new file mode 100644 index 00000000000..fbdecbb1918 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/listAppPasswords.ts @@ -0,0 +1,58 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + passwords: AppPassword[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export class AccountTakedownError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'AccountTakedown') return new AccountTakedownError(e) + } + return e +} + +export interface AppPassword { + name: string + createdAt: string + [k: string]: unknown +} + +export function isAppPassword(v: unknown): v is AppPassword { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.listAppPasswords#appPassword' + ) +} + +export function validateAppPassword(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.listAppPasswords#appPassword', v) +} diff --git a/packages/api/src/client/types/com/atproto/server/revokeAppPassword.ts b/packages/api/src/client/types/com/atproto/server/revokeAppPassword.ts new file mode 100644 index 00000000000..5ee78d8d42b --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/revokeAppPassword.ts @@ -0,0 +1,32 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + name: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/pds/src/api/com/atproto/server/createAccount.ts b/packages/pds/src/api/com/atproto/server/createAccount.ts index 742676932bd..306d553a8e4 100644 --- a/packages/pds/src/api/com/atproto/server/createAccount.ts +++ b/packages/pds/src/api/com/atproto/server/createAccount.ts @@ -115,9 +115,9 @@ export default function (server: Server, ctx: AppContext) { // Setup repo root await repoTxn.createRepo(did, [], now) - const access = ctx.auth.createAccessToken(did) - const refresh = ctx.auth.createRefreshToken(did) - await ctx.services.auth(dbTxn).grantRefreshToken(refresh.payload) + const access = ctx.auth.createAccessToken({ did }) + const refresh = ctx.auth.createRefreshToken({ did }) + await ctx.services.auth(dbTxn).grantRefreshToken(refresh.payload, null) return { did, diff --git a/packages/pds/src/api/com/atproto/server/createAppPassword.ts b/packages/pds/src/api/com/atproto/server/createAppPassword.ts new file mode 100644 index 00000000000..e86044ae524 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/createAppPassword.ts @@ -0,0 +1,18 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.createAppPassword({ + auth: ctx.accessVerifierNotAppPassword, + handler: async ({ auth, input }) => { + const { name } = input.body + const appPassword = await ctx.services + .account(ctx.db) + .createAppPassword(auth.credentials.did, name) + return { + encoding: 'application/json', + body: appPassword, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index e41986e6fbc..3604ded5a81 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -2,6 +2,7 @@ import { AuthRequiredError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' +import { AuthScope } from '../../../../auth' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.createSession(async ({ input }) => { @@ -18,10 +19,16 @@ export default function (server: Server, ctx: AppContext) { throw new AuthRequiredError('Invalid identifier or password') } - const validPass = await actorService.verifyUserPassword(user.did, password) - - if (!validPass) { - throw new AuthRequiredError('Invalid identifier or password') + let appPasswordName: string | null = null + const validAccountPass = await actorService.verifyAccountPassword( + user.did, + password, + ) + if (!validAccountPass) { + appPasswordName = await actorService.verifyAppPassword(user.did, password) + if (appPasswordName === null) { + throw new AuthRequiredError('Invalid identifier or password') + } } if (softDeleted(user)) { @@ -31,9 +38,12 @@ export default function (server: Server, ctx: AppContext) { ) } - const access = ctx.auth.createAccessToken(user.did) - const refresh = ctx.auth.createRefreshToken(user.did) - await authService.grantRefreshToken(refresh.payload) + const access = ctx.auth.createAccessToken({ + did: user.did, + scope: appPasswordName === null ? AuthScope.Access : AuthScope.AppPass, + }) + const refresh = ctx.auth.createRefreshToken({ did: user.did }) + await authService.grantRefreshToken(refresh.payload, appPasswordName) return { encoding: 'application/json', diff --git a/packages/pds/src/api/com/atproto/server/deleteAccount.ts b/packages/pds/src/api/com/atproto/server/deleteAccount.ts index 98c4991dda7..3578660899a 100644 --- a/packages/pds/src/api/com/atproto/server/deleteAccount.ts +++ b/packages/pds/src/api/com/atproto/server/deleteAccount.ts @@ -8,7 +8,7 @@ export default function (server: Server, ctx: AppContext) { const { did, password, token } = input.body const validPass = await ctx.services .account(ctx.db) - .verifyUserPassword(did, password) + .verifyAccountPassword(did, password) if (!validPass) { throw new AuthRequiredError('Invalid did or password') } diff --git a/packages/pds/src/api/com/atproto/server/deleteSession.ts b/packages/pds/src/api/com/atproto/server/deleteSession.ts index 729e0794223..28610df39d2 100644 --- a/packages/pds/src/api/com/atproto/server/deleteSession.ts +++ b/packages/pds/src/api/com/atproto/server/deleteSession.ts @@ -1,5 +1,5 @@ import { AuthRequiredError } from '@atproto/xrpc-server' -import { AuthScopes } from '../../../../auth' +import { AuthScope } from '../../../../auth' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' @@ -9,7 +9,7 @@ export default function (server: Server, ctx: AppContext) { if (!token) { throw new AuthRequiredError() } - const refreshToken = ctx.auth.verifyToken(token, AuthScopes.Refresh, { + const refreshToken = ctx.auth.verifyToken(token, [AuthScope.Refresh], { ignoreExpiration: true, }) if (!refreshToken.jti) { diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index cae967d6c1f..9a49216f71c 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -19,6 +19,10 @@ import deleteSession from './deleteSession' import getSession from './getSession' import refreshSession from './refreshSession' +import createAppPassword from './createAppPassword' +import listAppPasswords from './listAppPasswords' +import revokeAppPassword from './revokeAppPassword' + export default function (server: Server, ctx: AppContext) { describeServer(server, ctx) createAccount(server, ctx) @@ -33,4 +37,7 @@ export default function (server: Server, ctx: AppContext) { deleteSession(server, ctx) getSession(server, ctx) refreshSession(server, ctx) + createAppPassword(server, ctx) + listAppPasswords(server, ctx) + revokeAppPassword(server, ctx) } diff --git a/packages/pds/src/api/com/atproto/server/listAppPasswords.ts b/packages/pds/src/api/com/atproto/server/listAppPasswords.ts new file mode 100644 index 00000000000..3c7eff512f7 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/listAppPasswords.ts @@ -0,0 +1,17 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.listAppPasswords({ + auth: ctx.accessVerifier, + handler: async ({ auth }) => { + const passwords = await ctx.services + .account(ctx.db) + .listAppPasswords(auth.credentials.did) + return { + encoding: 'application/json', + body: { passwords }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/refreshSession.ts b/packages/pds/src/api/com/atproto/server/refreshSession.ts index ff9e2acc99a..0fda8ba48a7 100644 --- a/packages/pds/src/api/com/atproto/server/refreshSession.ts +++ b/packages/pds/src/api/com/atproto/server/refreshSession.ts @@ -2,6 +2,7 @@ import { AuthRequiredError, InvalidRequestError } from '@atproto/xrpc-server' import AppContext from '../../../../context' import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' +import { AuthScope } from '../../../../auth' export default function (server: Server, ctx: AppContext) { server.com.atproto.server.refreshSession({ @@ -23,33 +24,39 @@ export default function (server: Server, ctx: AppContext) { const lastRefreshId = ctx.auth.verifyToken( ctx.auth.getToken(req) ?? '', + [], ).jti if (!lastRefreshId) { throw new Error('Unexpected missing refresh token id') } - const access = ctx.auth.createAccessToken(user.did) - - const refresh = await ctx.db.transaction(async (dbTxn) => { + const res = await ctx.db.transaction(async (dbTxn) => { const authTxn = ctx.services.auth(dbTxn) - const nextId = await authTxn.rotateRefreshToken(lastRefreshId) - if (!nextId) return null - const refresh = ctx.auth.createRefreshToken(user.did, nextId) - await authTxn.grantRefreshToken(refresh.payload) - return refresh + const rotateRes = await authTxn.rotateRefreshToken(lastRefreshId) + if (!rotateRes) return null + const refresh = ctx.auth.createRefreshToken({ + did: user.did, + jti: rotateRes.nextId, + }) + await authTxn.grantRefreshToken(refresh.payload, rotateRes.appPassName) + return { refresh, appPassName: rotateRes.appPassName } }) - - if (refresh === null) { + if (res === null) { throw new InvalidRequestError('Token has been revoked', 'ExpiredToken') } + const access = ctx.auth.createAccessToken({ + did: user.did, + scope: res.appPassName === null ? AuthScope.Access : AuthScope.AppPass, + }) + return { encoding: 'application/json', body: { did: user.did, handle: user.handle, accessJwt: access.jwt, - refreshJwt: refresh.jwt, + refreshJwt: res.refresh.jwt, }, } }, diff --git a/packages/pds/src/api/com/atproto/server/revokeAppPassword.ts b/packages/pds/src/api/com/atproto/server/revokeAppPassword.ts new file mode 100644 index 00000000000..5f3ce4eb1cc --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/revokeAppPassword.ts @@ -0,0 +1,18 @@ +import AppContext from '../../../../context' +import { Server } from '../../../../lexicon' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.revokeAppPassword({ + auth: ctx.accessVerifier, + handler: async ({ auth, input }) => { + const requester = auth.credentials.did + const { name } = input.body + await ctx.db.transaction(async (dbTxn) => { + await ctx.services.account(dbTxn).deleteAppPassword(requester, name) + await ctx.services + .auth(dbTxn) + .revokeAppPasswordRefreshToken(requester, name) + }) + }, + }) +} diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index b92274e307e..ee732671a17 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -15,13 +15,14 @@ export type ServerAuthOpts = { } // @TODO sync-up with current method names, consider backwards compat. -export enum AuthScopes { +export enum AuthScope { Access = 'com.atproto.access', Refresh = 'com.atproto.refresh', + AppPass = 'com.atproto.appPass', } export type AuthToken = { - scope: AuthScopes + scope: AuthScope sub: string exp: number } @@ -37,57 +38,73 @@ export class ServerAuth { this._adminPass = opts.adminPass } - createAccessToken(did: string, expiresIn?: string | number) { + createAccessToken(opts: { + did: string + scope?: AuthScope + expiresIn?: string | number + }) { + const { did, scope = AuthScope.Access, expiresIn = '120mins' } = opts const payload = { - scope: AuthScopes.Access, + scope, sub: did, } return { payload: payload as AuthToken, // exp set by sign() jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn ?? '120mins', + expiresIn: expiresIn, mutatePayload: true, }), } } - createRefreshToken(did: string, jti?: string, expiresIn?: string | number) { + createRefreshToken(opts: { + did: string + jti?: string + expiresIn?: string | number + }) { + const { did, jti = getRefreshTokenId(), expiresIn = '90days' } = opts const payload = { - scope: AuthScopes.Refresh, + scope: AuthScope.Refresh, sub: did, - jti: jti ?? getRefreshTokenId(), + jti, } return { payload: payload as RefreshToken, // exp set by sign() jwt: jwt.sign(payload, this._secret, { - expiresIn: expiresIn ?? '90days', + expiresIn: expiresIn, mutatePayload: true, }), } } - getUserDid(req: express.Request, scope = AuthScopes.Access): string | null { + getCredentials( + req: express.Request, + scopes = [AuthScope.Access], + ): { did: string; scope: AuthScope } | null { const token = this.getToken(req) if (!token) return null - const payload = this.verifyToken(token, scope) + const payload = this.verifyToken(token, scopes) const sub = payload.sub if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - return sub + return { did: sub, scope: payload.scope } } - getUserDidOrThrow(req: express.Request, scope?: AuthScopes): string { - const did = this.getUserDid(req, scope) - if (did === null) { + getCredentialsOrThrow( + req: express.Request, + scopes: AuthScope[], + ): { did: string; scope: AuthScope } { + const creds = this.getCredentials(req, scopes) + if (creds === null) { throw new AuthRequiredError() } - return did + return creds } - verifyUser(req: express.Request, did: string, scope?: AuthScopes): boolean { - const authorized = this.getUserDid(req, scope) - return authorized === did + verifyUser(req: express.Request, did: string, scopes: AuthScope[]): boolean { + const authorized = this.getCredentials(req, scopes) + return authorized !== null && authorized.did === did } verifyAdmin(req: express.Request): boolean { @@ -105,13 +122,17 @@ export class ServerAuth { return header.slice(BEARER.length) } - verifyToken(token: string, scope?: AuthScopes, options?: jwt.VerifyOptions) { + verifyToken( + token: string, + scopes: AuthScope[], + options?: jwt.VerifyOptions, + ): jwt.JwtPayload { try { const payload = jwt.verify(token, this._secret, options) if (typeof payload === 'string' || 'signature' in payload) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - if (scope && payload.scope !== scope) { + if (scopes.length > 0 && !scopes.includes(payload.scope)) { throw new InvalidRequestError('Bad token scope', 'InvalidToken') } return payload @@ -152,11 +173,22 @@ export const parseBasicAuth = ( export const accessVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { + const creds = auth.getCredentialsOrThrow(ctx.req, [ + AuthScope.Access, + AuthScope.AppPass, + ]) return { - credentials: { - did: auth.getUserDidOrThrow(ctx.req, AuthScopes.Access), - scope: AuthScopes.Access, - }, + credentials: creds, + artifacts: auth.getToken(ctx.req), + } + } + +export const accessVerifierNotAppPassword = + (auth: ServerAuth) => + async (ctx: { req: express.Request; res: express.Response }) => { + const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Access]) + return { + credentials: creds, artifacts: auth.getToken(ctx.req), } } @@ -164,8 +196,11 @@ export const accessVerifier = export const accessVerifierCheckTakedown = (auth: ServerAuth, { db, services }: AppContext) => async (ctx: { req: express.Request; res: express.Response }) => { - const did = auth.getUserDidOrThrow(ctx.req, AuthScopes.Access) - const actor = await services.account(db).getAccount(did, true) + const creds = auth.getCredentialsOrThrow(ctx.req, [ + AuthScope.Access, + AuthScope.AppPass, + ]) + const actor = await services.account(db).getAccount(creds.did, true) if (!actor || softDeleted(actor)) { throw new AuthRequiredError( 'Account has been taken down', @@ -173,10 +208,7 @@ export const accessVerifierCheckTakedown = ) } return { - credentials: { - did, - scope: AuthScopes.Access, - }, + credentials: creds, artifacts: auth.getToken(ctx.req), } } @@ -184,11 +216,9 @@ export const accessVerifierCheckTakedown = export const refreshVerifier = (auth: ServerAuth) => async (ctx: { req: express.Request; res: express.Response }) => { + const creds = auth.getCredentialsOrThrow(ctx.req, [AuthScope.Refresh]) return { - credentials: { - did: auth.getUserDidOrThrow(ctx.req, AuthScopes.Refresh), - scope: AuthScopes.Refresh, - }, + credentials: creds, artifacts: auth.getToken(ctx.req), } } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index f68e507ec26..f769106e818 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -54,6 +54,10 @@ export class AppContext { return auth.accessVerifier(this.auth) } + get accessVerifierNotAppPassword() { + return auth.accessVerifierNotAppPassword(this.auth) + } + get accessVerifierCheckTakedown() { return auth.accessVerifierCheckTakedown(this.auth, this) } diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index 9ffcc971ab8..17e7fa316c1 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -4,6 +4,7 @@ import * as userState from './tables/user-state' import * as didHandle from './tables/did-handle' import * as repoRoot from './tables/repo-root' import * as refreshToken from './tables/refresh-token' +import * as appPassword from './tables/app-password' import * as record from './tables/record' import * as backlink from './tables/backlink' import * as repoCommitBlock from './tables/repo-commit-block' @@ -27,6 +28,7 @@ export type DatabaseSchemaType = appView.DatabaseSchemaType & userState.PartialDB & didHandle.PartialDB & refreshToken.PartialDB & + appPassword.PartialDB & repoRoot.PartialDB & record.PartialDB & backlink.PartialDB & diff --git a/packages/pds/src/db/migrations/20230416T221236745Z-app-specific-passwords.ts b/packages/pds/src/db/migrations/20230416T221236745Z-app-specific-passwords.ts new file mode 100644 index 00000000000..f518188c844 --- /dev/null +++ b/packages/pds/src/db/migrations/20230416T221236745Z-app-specific-passwords.ts @@ -0,0 +1,26 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('refresh_token') + .addColumn('appPasswordName', 'varchar') + .execute() + + await db.schema + .createTable('app_password') + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('name', 'varchar', (col) => col.notNull()) + .addColumn('passwordScrypt', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addPrimaryKeyConstraint('app_password_pkey', ['did', 'name']) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('app_password').execute() + + await db.schema + .alterTable('refresh_token') + .dropColumn('appPasswordName') + .execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index d088b3df9f4..f3d389df006 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -39,3 +39,4 @@ export * as _20230406T185855842Z from './20230406T185855842Z-feed-item-init' export * as _20230411T175730759Z from './20230411T175730759Z-drop-message-queue' export * as _20230411T180247652Z from './20230411T180247652Z-labels' export * as _20230412T231807162Z from './20230412T231807162Z-moderation-action-labels' +export * as _20230416T221236745Z from './20230416T221236745Z-app-specific-passwords' diff --git a/packages/pds/src/db/scrypt.ts b/packages/pds/src/db/scrypt.ts index 40eaa44cf25..f0b61e3fca2 100644 --- a/packages/pds/src/db/scrypt.ts +++ b/packages/pds/src/db/scrypt.ts @@ -1,24 +1,50 @@ import crypto from 'crypto' +import { sha256 } from '@atproto/crypto' +import * as ui8 from 'uint8arrays' -export const hash = (password: string): Promise => { +export const genSaltAndHash = (password: string): Promise => { + const salt = crypto.randomBytes(16).toString('hex') + return hashWithSalt(password, salt) +} + +export const hashWithSalt = ( + password: string, + salt: string, +): Promise => { return new Promise((resolve, reject) => { - const salt = crypto.randomBytes(16).toString('hex') crypto.scrypt(password, salt, 64, (err, hash) => { - if (err) reject(err) + if (err) return reject(err) resolve(salt + ':' + hash.toString('hex')) }) }) } -export const verify = ( +export const verify = async ( password: string, storedHash: string, ): Promise => { + const [salt, hash] = storedHash.split(':') + const derivedHash = await getDerivedHash(password, salt) + return hash === derivedHash +} + +export const getDerivedHash = ( + password: string, + salt: string, +): Promise => { return new Promise((resolve, reject) => { - const [salt, hash] = storedHash.split(':') crypto.scrypt(password, salt, 64, (err, derivedHash) => { - if (err) reject(err) - resolve(hash === derivedHash.toString('hex')) + if (err) return reject(err) + resolve(derivedHash.toString('hex')) }) }) } + +export const hashAppPassword = async ( + did: string, + password: string, +): Promise => { + const sha = await sha256(did) + const salt = ui8.toString(sha.slice(0, 16), 'hex') + return hashWithSalt(password, salt) +} diff --git a/packages/pds/src/db/tables/app-password.ts b/packages/pds/src/db/tables/app-password.ts new file mode 100644 index 00000000000..ce3594ce3f1 --- /dev/null +++ b/packages/pds/src/db/tables/app-password.ts @@ -0,0 +1,10 @@ +export interface AppPassword { + did: string + name: string + passwordScrypt: string + createdAt: string +} + +export const tableName = 'app_password' + +export type PartialDB = { [tableName]: AppPassword } diff --git a/packages/pds/src/db/tables/refresh-token.ts b/packages/pds/src/db/tables/refresh-token.ts index 15fb25abdae..bd29614f316 100644 --- a/packages/pds/src/db/tables/refresh-token.ts +++ b/packages/pds/src/db/tables/refresh-token.ts @@ -2,6 +2,7 @@ export interface RefreshToken { id: string did: string expiresAt: string + appPasswordName: string | null nextId: string | null } diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 4e3667c1953..ba388da4ea5 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -37,6 +37,7 @@ import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' +import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' import * as ComAtprotoServerCreateInviteCodes from './types/com/atproto/server/createInviteCodes' import * as ComAtprotoServerCreateSession from './types/com/atproto/server/createSession' @@ -45,10 +46,12 @@ import * as ComAtprotoServerDeleteSession from './types/com/atproto/server/delet import * as ComAtprotoServerDescribeServer from './types/com/atproto/server/describeServer' import * as ComAtprotoServerGetAccountInviteCodes from './types/com/atproto/server/getAccountInviteCodes' import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSession' +import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' +import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -419,6 +422,16 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + createAppPassword( + cfg: ConfigOf< + AV, + ComAtprotoServerCreateAppPassword.Handler> + >, + ) { + const nsid = 'com.atproto.server.createAppPassword' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + createInviteCode( cfg: ConfigOf< AV, @@ -484,6 +497,16 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + listAppPasswords( + cfg: ConfigOf< + AV, + ComAtprotoServerListAppPasswords.Handler> + >, + ) { + const nsid = 'com.atproto.server.listAppPasswords' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + refreshSession( cfg: ConfigOf>>, ) { @@ -517,6 +540,16 @@ export class ServerNS { const nsid = 'com.atproto.server.resetPassword' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + revokeAppPassword( + cfg: ConfigOf< + AV, + ComAtprotoServerRevokeAppPassword.Handler> + >, + ) { + const nsid = 'com.atproto.server.revokeAppPassword' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class SyncNS { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 76c912cfa6e..25dd2a3651f 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2075,6 +2075,56 @@ export const schemaDict = { }, }, }, + ComAtprotoServerCreateAppPassword: { + lexicon: 1, + id: 'com.atproto.server.createAppPassword', + defs: { + main: { + type: 'procedure', + description: 'Create an app-specific password.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'ref', + ref: 'lex:com.atproto.server.createAppPassword#appPassword', + }, + }, + errors: [ + { + name: 'AccountTakedown', + }, + ], + }, + appPassword: { + type: 'object', + required: ['name', 'password', 'createdAt'], + properties: { + name: { + type: 'string', + }, + password: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerCreateInviteCode: { lexicon: 1, id: 'com.atproto.server.createInviteCode', @@ -2441,6 +2491,50 @@ export const schemaDict = { }, }, }, + ComAtprotoServerListAppPasswords: { + lexicon: 1, + id: 'com.atproto.server.listAppPasswords', + defs: { + main: { + type: 'query', + description: 'List all app-specific passwords.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['passwords'], + properties: { + passwords: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:com.atproto.server.listAppPasswords#appPassword', + }, + }, + }, + }, + }, + errors: [ + { + name: 'AccountTakedown', + }, + ], + }, + appPassword: { + type: 'object', + required: ['name', 'createdAt'], + properties: { + name: { + type: 'string', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, ComAtprotoServerRefreshSession: { lexicon: 1, id: 'com.atproto.server.refreshSession', @@ -2544,6 +2638,28 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRevokeAppPassword: { + lexicon: 1, + id: 'com.atproto.server.revokeAppPassword', + defs: { + main: { + type: 'procedure', + description: 'Revoke an app-specific password by name.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -4842,6 +4958,7 @@ export const ids = { ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', + ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', ComAtprotoServerCreateInviteCodes: 'com.atproto.server.createInviteCodes', ComAtprotoServerCreateSession: 'com.atproto.server.createSession', @@ -4852,12 +4969,14 @@ export const ids = { ComAtprotoServerGetAccountInviteCodes: 'com.atproto.server.getAccountInviteCodes', ComAtprotoServerGetSession: 'com.atproto.server.getSession', + ComAtprotoServerListAppPasswords: 'com.atproto.server.listAppPasswords', ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', + ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createAppPassword.ts b/packages/pds/src/lexicon/types/com/atproto/server/createAppPassword.ts new file mode 100644 index 00000000000..65673ffbc1d --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/createAppPassword.ts @@ -0,0 +1,65 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + name: string + [k: string]: unknown +} + +export type OutputSchema = AppPassword + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountTakedown' +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput + +export interface AppPassword { + name: string + password: string + createdAt: string + [k: string]: unknown +} + +export function isAppPassword(v: unknown): v is AppPassword { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.createAppPassword#appPassword' + ) +} + +export function validateAppPassword(v: unknown): ValidationResult { + return lexicons.validate( + 'com.atproto.server.createAppPassword#appPassword', + v, + ) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/server/listAppPasswords.ts b/packages/pds/src/lexicon/types/com/atproto/server/listAppPasswords.ts new file mode 100644 index 00000000000..012239b2d15 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/listAppPasswords.ts @@ -0,0 +1,58 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + passwords: AppPassword[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountTakedown' +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput + +export interface AppPassword { + name: string + createdAt: string + [k: string]: unknown +} + +export function isAppPassword(v: unknown): v is AppPassword { + return ( + isObj(v) && + hasProp(v, '$type') && + v.$type === 'com.atproto.server.listAppPasswords#appPassword' + ) +} + +export function validateAppPassword(v: unknown): ValidationResult { + return lexicons.validate('com.atproto.server.listAppPasswords#appPassword', v) +} diff --git a/packages/pds/src/lexicon/types/com/atproto/server/revokeAppPassword.ts b/packages/pds/src/lexicon/types/com/atproto/server/revokeAppPassword.ts new file mode 100644 index 00000000000..e6bdcd09801 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/revokeAppPassword.ts @@ -0,0 +1,35 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + name: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type Handler = (ctx: { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +}) => Promise | HandlerOutput diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 1f7f429c3b8..b1715a55260 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -9,6 +9,9 @@ import { countAll, notSoftDeletedClause, nullToZero } from '../../db/util' import { getUserSearchQueryPg, getUserSearchQuerySqlite } from '../util/search' import { paginate, TimeCidKeyset } from '../../db/pagination' import { sequenceHandleUpdate } from '../../sequencer' +import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' +import { randomStr } from '@atproto/crypto' +import { InvalidRequestError } from '@atproto/xrpc-server' export class AccountService { constructor(public db: Database) {} @@ -94,7 +97,7 @@ export class AccountService { .values({ email: email.toLowerCase(), did, - passwordScrypt: await scrypt.hash(password), + passwordScrypt: await scrypt.genSaltAndHash(password), createdAt: new Date().toISOString(), }) .onConflict((oc) => oc.doNothing()) @@ -154,7 +157,7 @@ export class AccountService { } async updateUserPassword(did: string, password: string) { - const passwordScrypt = await scrypt.hash(password) + const passwordScrypt = await scrypt.genSaltAndHash(password) await this.db.db .updateTable('user_account') .set({ passwordScrypt }) @@ -162,14 +165,77 @@ export class AccountService { .execute() } - async verifyUserPassword(did: string, password: string): Promise { + async createAppPassword(did: string, name: string): Promise { + // create an app password with format: + // 1234-abcd-5678-efgh + const str = randomStr(16, 'base32').slice(0, 16) + const chunks = [ + str.slice(0, 4), + str.slice(4, 8), + str.slice(8, 12), + str.slice(12, 16), + ] + const password = chunks.join('-') + const passwordScrypt = await scrypt.hashAppPassword(did, password) + const got = await this.db.db + .insertInto('app_password') + .values({ + did, + name, + passwordScrypt, + createdAt: new Date().toISOString(), + }) + .returningAll() + .executeTakeFirst() + if (!got) { + throw new InvalidRequestError('could not create app-specific password') + } + return { + name, + password, + createdAt: got.createdAt, + } + } + + async deleteAppPassword(did: string, name: string) { + await this.db.db + .deleteFrom('app_password') + .where('did', '=', did) + .where('name', '=', name) + .execute() + } + + async verifyAccountPassword(did: string, password: string): Promise { const found = await this.db.db .selectFrom('user_account') .selectAll() .where('did', '=', did) .executeTakeFirst() - if (!found) return false - return scrypt.verify(password, found.passwordScrypt) + return found ? await scrypt.verify(password, found.passwordScrypt) : false + } + + async verifyAppPassword( + did: string, + password: string, + ): Promise { + const passwordScrypt = await scrypt.hashAppPassword(did, password) + const found = await this.db.db + .selectFrom('app_password') + .selectAll() + .where('did', '=', did) + .where('passwordScrypt', '=', passwordScrypt) + .executeTakeFirst() + return found?.name ?? null + } + + async listAppPasswords( + did: string, + ): Promise<{ name: string; createdAt: string }[]> { + return this.db.db + .selectFrom('app_password') + .select(['name', 'createdAt']) + .where('did', '=', did) + .execute() } async mute(info: { did: string; mutedByDid: string; createdAt?: Date }) { diff --git a/packages/pds/src/services/auth.ts b/packages/pds/src/services/auth.ts index 1bb48086fde..7ad45be79cf 100644 --- a/packages/pds/src/services/auth.ts +++ b/packages/pds/src/services/auth.ts @@ -11,19 +11,25 @@ export class AuthService { return (db: Database) => new AuthService(db) } - async grantRefreshToken(payload: RefreshToken) { + async grantRefreshToken( + payload: RefreshToken, + appPasswordName: string | null, + ) { return this.db.db .insertInto('refresh_token') .values({ id: payload.jti, did: payload.sub, + appPasswordName, expiresAt: new Date(payload.exp * 1000).toISOString(), }) .onConflict((oc) => oc.doNothing()) // E.g. when re-granting during a refresh grace period .executeTakeFirst() } - async rotateRefreshToken(id: string) { + async rotateRefreshToken( + id: string, + ): Promise<{ nextId: string; appPassName: string | null } | null> { this.db.assertTransaction() const token = await this.db.db .selectFrom('refresh_token') @@ -63,7 +69,7 @@ export class AuthService { .returningAll() .executeTakeFirst() - return expired ? null : nextId + return expired ? null : { nextId, appPassName: token.appPasswordName } } async revokeRefreshToken(id: string) { @@ -81,4 +87,13 @@ export class AuthService { .executeTakeFirst() return numDeletedRows > 0 } + + async revokeAppPasswordRefreshToken(did: string, appPassName: string) { + const { numDeletedRows } = await this.db.db + .deleteFrom('refresh_token') + .where('did', '=', did) + .where('appPasswordName', '=', appPassName) + .executeTakeFirst() + return numDeletedRows > 0 + } } diff --git a/packages/pds/tests/app-passwords.test.ts b/packages/pds/tests/app-passwords.test.ts new file mode 100644 index 00000000000..c67b335bef7 --- /dev/null +++ b/packages/pds/tests/app-passwords.test.ts @@ -0,0 +1,138 @@ +import AtpAgent from '@atproto/api' +import * as jwt from 'jsonwebtoken' +import { CloseFn, runTestServer, TestServerInfo } from './_util' + +describe('app_passwords', () => { + let server: TestServerInfo + let accntAgent: AtpAgent + let appAgent: AtpAgent + let close: CloseFn + + beforeAll(async () => { + server = await runTestServer({ + dbPostgresSchema: 'app_passwords', + }) + accntAgent = new AtpAgent({ service: server.url }) + appAgent = new AtpAgent({ service: server.url }) + close = server.close + + await accntAgent.createAccount({ + handle: 'alice.test', + email: 'alice@test.com', + password: 'alice-pass', + }) + }) + + afterAll(async () => { + await close() + }) + + let appPass: string + + it('creates an app-specific password', async () => { + const res = await accntAgent.api.com.atproto.server.createAppPassword({ + name: 'test-pass', + }) + expect(res.data.name).toBe('test-pass') + appPass = res.data.password + }) + + it('creates a session with an app-specific password', async () => { + const res = await appAgent.login({ + identifier: 'alice.test', + password: appPass, + }) + expect(res.data.did).toEqual(accntAgent.session?.did) + }) + + it('creates an access token for an app with a restricted scope', () => { + const decoded = jwt.decode(appAgent.session?.accessJwt ?? '', { + json: true, + }) + expect(decoded?.scope).toEqual('com.atproto.appPass') + }) + + it('allows actions to be performed from app', async () => { + await appAgent.api.app.bsky.feed.post.create( + { + repo: appAgent.session?.did, + }, + { + text: 'Testing testing', + createdAt: new Date().toISOString(), + }, + ) + }) + + it('restricts certain actions', async () => { + const attempt = appAgent.api.com.atproto.server.createAppPassword({ + name: 'another-one', + }) + await expect(attempt).rejects.toThrow('Token could not be verified') + }) + + it('persists scope across refreshes', async () => { + const session = await appAgent.api.com.atproto.server.refreshSession( + undefined, + { + headers: { + authorization: `Bearer ${appAgent.session?.refreshJwt}`, + }, + }, + ) + + await appAgent.api.app.bsky.feed.post.create( + { + repo: appAgent.session?.did, + }, + { + text: 'Testing testing', + createdAt: new Date().toISOString(), + }, + { + authorization: `Bearer ${session.data.accessJwt}`, + }, + ) + + const attempt = appAgent.api.com.atproto.server.createAppPassword( + { + name: 'another-one', + }, + { + encoding: 'application/json', + headers: { authorization: `Bearer ${session.data.accessJwt}` }, + }, + ) + await expect(attempt).rejects.toThrow('Token could not be verified') + }) + + it('lists available app-specific passwords', async () => { + const res = await appAgent.api.com.atproto.server.listAppPasswords() + expect(res.data.passwords.length).toBe(1) + expect(res.data.passwords[0].name).toEqual('test-pass') + }) + + it('revokes an app-specific password', async () => { + await appAgent.api.com.atproto.server.revokeAppPassword({ + name: 'test-pass', + }) + }) + + it('no longer allows session refresh after revocation', async () => { + const attempt = appAgent.api.com.atproto.server.refreshSession(undefined, { + headers: { + authorization: `Bearer ${appAgent.session?.refreshJwt}`, + }, + }) + await expect(attempt).rejects.toThrow('Token has been revoked') + }) + + it('no longer allows session creation after revocation', async () => { + const newAgent = new AtpAgent({ service: server.url }) + const attempt = newAgent.login({ + identifier: 'alice.test', + password: appPass, + }) + await expect(attempt).rejects.toThrow('Invalid identifier or password') + }) +}) diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index f0ffbfa6b72..c1883d5a7f7 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -231,7 +231,7 @@ describe('auth', () => { email: 'holga@test.com', password: 'password', }) - const refresh = auth.createRefreshToken(account.did, undefined, -1) + const refresh = auth.createRefreshToken({ did: account.did, expiresIn: -1 }) const refreshExpired = refreshSession(refresh.jwt) await expect(refreshExpired).rejects.toThrow('Token has expired') await deleteSession(refresh.jwt) // No problem revoking an expired token