dee817b6e0
* Re-use code definition of oauthResponseTypeSchema * Generate proper invalid_authorization_details * Remove OpenID compatibility * tidy * properly verify presence of jti claim in client assertion * Remove non-standard "sub" from OAuthTokenResponse * Remove nonce from authorization request * tidy * Enforce uniqueness of code_challenge * remove unused "atproto" scope * Improve reporting of validation errors * Allow empty set of scopes * Do not remove scopes not advertised in the AS's "scopes_supported" when building the authorization request. * Prevent empty scope string * Remove invalid check from token response * remove un-necessary session refresh * Validate scopes characters according to OAuth 2.1 spec * Mandate the use of "atproto" scope * Disable ability to list app passwords when using an app password * Use locally defined authPassthru in com.atproto.admin.* handlers * provide proper production handle resolver in example * properly compote login method * feat(oauth-provider): always rotate session cookie on sign-in * feat(oauth-provider): do not require consent from first party apps * update request parameter's prompt before other param validation checks * feat(oauth-provider): rework display of client name * feat(oauth-client-browser:example): add token info introspection * feat(oauth-client-browser:example): allow defining scope globally * Display requested scopes during the auth flow * Add, and verify, a "typ" header to access and refresh tokens * Ignore case when checking for dpop auth scheme * Add "jwtAlg" option to verifySignature() function * Verify service JWT header values. Add iat claim to service JWT * Add support for "transition:generic" and "transition:chat.bsky" oauth scopes in PDS * oauth-client-browser(example): add scope request * Add missing "atproto" scope * Allow missing 'typ' claim in service auth jwt * Improved 401 feedback Co-authored-by: devin ivy <devinivy@gmail.com> * Properly parse scopes upon verification Co-authored-by: devin ivy <devinivy@gmail.com> * Rename "atp" to "credential" auth in oauth-client-browser example * add key to iteration items * Make CORS protection stronger * Allow OAuthProvider to define its own CORS policies * Revert "Allow missing 'typ' claim in service auth jwt" This reverts commit 15c6b9e2197064eb5de61a96de6497060edb824e. * Revert "Verify service JWT header values. Add iat claim to service JWT" This reverts commit 08df8df322a3f4b631c4a63a61d55b2c84c60c11. * Revert "Add "jwtAlg" option to verifySignature() function" This reverts commit d0f77354e6904678e7f5d76bb026f07537443ba9. * Revert "Add, and verify, a "typ" header to access and refresh tokens" This reverts commit 3e21be9e4b5875caa5e862c11f2196786fb2366d. * pds: implement protected service auth methods * Prevent app password management using sessions initiated from an app password. * Alphabetically sort PROTECTED_METHODS * Revert changes to app password management permissions * tidy --------- Co-authored-by: devin ivy <devinivy@gmail.com>
223 lines
7.9 KiB
TypeScript
223 lines
7.9 KiB
TypeScript
import * as os from 'node:os'
|
|
import * as path from 'node:path'
|
|
import assert from 'node:assert'
|
|
import { decodeJwt } from 'jose'
|
|
import * as plcLib from '@did-plc/lib'
|
|
import { parseReqNsid } from '@atproto/xrpc-server'
|
|
import { AtpAgent } from '@atproto/api'
|
|
import { Secp256k1Keypair, randomStr } from '@atproto/crypto'
|
|
import { SeedClient, TestPds, TestPlc, mockResolvers } from '@atproto/dev-env'
|
|
import * as pdsEntryway from '@atproto/pds-entryway'
|
|
import * as ui8 from 'uint8arrays'
|
|
import getPort from 'get-port'
|
|
|
|
describe('entryway', () => {
|
|
let plc: TestPlc
|
|
let pds: TestPds
|
|
let entryway: pdsEntryway.PDS
|
|
let pdsAgent: AtpAgent
|
|
let entrywayAgent: AtpAgent
|
|
let alice: string
|
|
let accessToken: string
|
|
|
|
beforeAll(async () => {
|
|
const jwtSigningKey = await Secp256k1Keypair.create({ exportable: true })
|
|
const plcRotationKey = await Secp256k1Keypair.create({ exportable: true })
|
|
const entrywayPort = await getPort()
|
|
plc = await TestPlc.create({})
|
|
pds = await TestPds.create({
|
|
entrywayUrl: `http://localhost:${entrywayPort}`,
|
|
entrywayDid: 'did:example:entryway',
|
|
entrywayJwtVerifyKeyK256PublicKeyHex: getPublicHex(jwtSigningKey),
|
|
entrywayPlcRotationKey: plcRotationKey.did(),
|
|
adminPassword: 'admin-pass',
|
|
serviceHandleDomains: [],
|
|
didPlcUrl: plc.url,
|
|
serviceDid: 'did:example:pds',
|
|
inviteRequired: false,
|
|
})
|
|
entryway = await createEntryway({
|
|
dbPostgresSchema: 'entryway',
|
|
port: entrywayPort,
|
|
adminPassword: 'admin-pass',
|
|
jwtSigningKeyK256PrivateKeyHex: await getPrivateHex(jwtSigningKey),
|
|
plcRotationKeyK256PrivateKeyHex: await getPrivateHex(plcRotationKey),
|
|
inviteRequired: false,
|
|
serviceDid: 'did:example:entryway',
|
|
didPlcUrl: plc.url,
|
|
})
|
|
mockResolvers(pds.ctx.idResolver, pds)
|
|
mockResolvers(entryway.ctx.idResolver, pds)
|
|
await entryway.ctx.db.db
|
|
.insertInto('pds')
|
|
.values({
|
|
did: pds.ctx.cfg.service.did,
|
|
host: new URL(pds.ctx.cfg.service.publicUrl).host,
|
|
weight: 1,
|
|
})
|
|
.execute()
|
|
pdsAgent = pds.getClient()
|
|
entrywayAgent = new AtpAgent({
|
|
service: entryway.ctx.cfg.service.publicUrl,
|
|
})
|
|
})
|
|
|
|
afterAll(async () => {
|
|
await plc.close()
|
|
await entryway.destroy()
|
|
await pds.close()
|
|
})
|
|
|
|
it('creates account.', async () => {
|
|
const res = await entrywayAgent.api.com.atproto.server.createAccount({
|
|
email: 'alice@test.com',
|
|
handle: 'alice.test',
|
|
password: 'test123',
|
|
})
|
|
alice = res.data.did
|
|
accessToken = res.data.accessJwt
|
|
|
|
const account = await pds.ctx.accountManager.getAccount(alice)
|
|
expect(account?.did).toEqual(alice)
|
|
expect(account?.handle).toEqual('alice.test')
|
|
})
|
|
|
|
it('auths with both services.', async () => {
|
|
const entrywaySession =
|
|
await entrywayAgent.api.com.atproto.server.getSession(undefined, {
|
|
headers: SeedClient.getHeaders(accessToken),
|
|
})
|
|
const pdsSession = await pdsAgent.api.com.atproto.server.getSession(
|
|
undefined,
|
|
{ headers: SeedClient.getHeaders(accessToken) },
|
|
)
|
|
expect(entrywaySession.data).toEqual(pdsSession.data)
|
|
})
|
|
|
|
it('updates handle from pds.', async () => {
|
|
await pdsAgent.api.com.atproto.identity.updateHandle(
|
|
{ handle: 'alice2.test' },
|
|
{
|
|
headers: SeedClient.getHeaders(accessToken),
|
|
encoding: 'application/json',
|
|
},
|
|
)
|
|
const doc = await pds.ctx.idResolver.did.resolve(alice)
|
|
const handleToDid = await pds.ctx.idResolver.handle.resolve('alice2.test')
|
|
const accountFromPds = await pds.ctx.accountManager.getAccount(alice)
|
|
const accountFromEntryway = await entryway.ctx.services
|
|
.account(entryway.ctx.db)
|
|
.getAccount(alice)
|
|
expect(doc?.alsoKnownAs).toEqual(['at://alice2.test'])
|
|
expect(handleToDid).toEqual(alice)
|
|
expect(accountFromPds?.handle).toEqual('alice2.test')
|
|
expect(accountFromEntryway?.handle).toEqual('alice2.test')
|
|
})
|
|
|
|
it('updates handle from entryway.', async () => {
|
|
await entrywayAgent.api.com.atproto.identity.updateHandle(
|
|
{ handle: 'alice3.test' },
|
|
await pds.ctx.serviceAuthHeaders(
|
|
alice,
|
|
'did:example:entryway',
|
|
'com.atproto.identity.updateHandle',
|
|
),
|
|
)
|
|
const doc = await entryway.ctx.idResolver.did.resolve(alice)
|
|
const handleToDid =
|
|
await entryway.ctx.idResolver.handle.resolve('alice3.test')
|
|
const accountFromPds = await pds.ctx.accountManager.getAccount(alice)
|
|
const accountFromEntryway = await entryway.ctx.services
|
|
.account(entryway.ctx.db)
|
|
.getAccount(alice)
|
|
expect(doc?.alsoKnownAs).toEqual(['at://alice3.test'])
|
|
expect(handleToDid).toEqual(alice)
|
|
expect(accountFromPds?.handle).toEqual('alice3.test')
|
|
expect(accountFromEntryway?.handle).toEqual('alice3.test')
|
|
})
|
|
|
|
it('does not allow bringing own op to account creation.', async () => {
|
|
const {
|
|
data: { signingKey },
|
|
} = await pdsAgent.api.com.atproto.server.reserveSigningKey({})
|
|
const rotationKey = await Secp256k1Keypair.create()
|
|
const plcCreate = await plcLib.createOp({
|
|
signingKey,
|
|
rotationKeys: [rotationKey.did(), entryway.ctx.plcRotationKey.did()],
|
|
handle: 'weirdalice.test',
|
|
pds: pds.ctx.cfg.service.publicUrl,
|
|
signer: rotationKey,
|
|
})
|
|
const tryCreateAccount = pdsAgent.api.com.atproto.server.createAccount({
|
|
did: plcCreate.did,
|
|
plcOp: plcCreate.op,
|
|
handle: 'weirdalice.test',
|
|
})
|
|
await expect(tryCreateAccount).rejects.toThrow('invalid plc operation')
|
|
})
|
|
})
|
|
|
|
const createEntryway = async (
|
|
config: pdsEntryway.ServerEnvironment & {
|
|
adminPassword: string
|
|
jwtSigningKeyK256PrivateKeyHex: string
|
|
plcRotationKeyK256PrivateKeyHex: string
|
|
},
|
|
) => {
|
|
const signingKey = await Secp256k1Keypair.create({ exportable: true })
|
|
const recoveryKey = await Secp256k1Keypair.create({ exportable: true })
|
|
const env: pdsEntryway.ServerEnvironment = {
|
|
isEntryway: true,
|
|
recoveryDidKey: recoveryKey.did(),
|
|
serviceHandleDomains: ['.test'],
|
|
dbPostgresUrl: process.env.DB_POSTGRES_URL,
|
|
blobstoreDiskLocation: path.join(os.tmpdir(), randomStr(8, 'base32')),
|
|
bskyAppViewUrl: 'https://appview.invalid',
|
|
bskyAppViewDid: 'did:example:invalid',
|
|
bskyAppViewCdnUrlPattern: 'http://cdn.appview.com/%s/%s/%s',
|
|
jwtSecret: randomStr(8, 'base32'),
|
|
repoSigningKeyK256PrivateKeyHex: await getPrivateHex(signingKey),
|
|
modServiceUrl: 'https://mod.invalid',
|
|
modServiceDid: 'did:example:invalid',
|
|
...config,
|
|
}
|
|
const cfg = pdsEntryway.envToCfg(env)
|
|
const secrets = pdsEntryway.envToSecrets(env)
|
|
const server = await pdsEntryway.PDS.create(cfg, secrets)
|
|
await server.ctx.db.migrateToLatestOrThrow()
|
|
await server.start()
|
|
// patch entryway access token verification to handle internal service auth pds -> entryway
|
|
const origValidateAccessToken =
|
|
server.ctx.authVerifier.validateAccessToken.bind(server.ctx.authVerifier)
|
|
server.ctx.authVerifier.validateAccessToken = async (req, scopes) => {
|
|
const jwt = req.headers.authorization?.replace('Bearer ', '') ?? ''
|
|
const claims = decodeJwt(jwt)
|
|
if (claims.aud === 'did:example:entryway') {
|
|
assert(claims.lxm === parseReqNsid(req), 'bad lxm claim in service auth')
|
|
assert(claims.aud, 'missing aud claim in service auth')
|
|
assert(claims.iss, 'missing iss claim in service auth')
|
|
return {
|
|
artifacts: jwt,
|
|
credentials: {
|
|
type: 'access',
|
|
scope: 'com.atproto.access' as any,
|
|
audience: claims.aud,
|
|
did: claims.iss,
|
|
},
|
|
}
|
|
}
|
|
return origValidateAccessToken(req, scopes)
|
|
}
|
|
// @TODO temp hack because entryway teardown calls signupActivator.run() by mistake
|
|
server.ctx.signupActivator.run = server.ctx.signupActivator.destroy
|
|
return server
|
|
}
|
|
|
|
const getPublicHex = (key: Secp256k1Keypair) => {
|
|
return key.publicKeyStr('hex')
|
|
}
|
|
|
|
const getPrivateHex = async (key: Secp256k1Keypair) => {
|
|
return ui8.toString(await key.export(), 'hex')
|
|
}
|