atproto/packages/pds/tests/app-passwords.test.ts
Matthieu Sieben dee817b6e0
OAuth: Add authorization scopes & remove OpenID compatibility (#2734)
* 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>
2024-08-27 13:43:29 -04:00

253 lines
7.5 KiB
TypeScript

import { AtpAgent } from '@atproto/api'
import { TestNetworkNoAppView } from '@atproto/dev-env'
import * as jose from 'jose'
describe('app_passwords', () => {
let network: TestNetworkNoAppView
let accntAgent: AtpAgent
let appAgent: AtpAgent
let priviAgent: AtpAgent
beforeAll(async () => {
network = await TestNetworkNoAppView.create({
dbPostgresSchema: 'app_passwords',
})
accntAgent = network.pds.getClient()
appAgent = network.pds.getClient()
priviAgent = network.pds.getClient()
await accntAgent.createAccount({
handle: 'alice.test',
email: 'alice@test.com',
password: 'alice-pass',
})
})
afterAll(async () => {
await network.close()
})
let appPass: string
let privilegedAppPass: 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')
expect(res.data.privileged).toBe(false)
appPass = res.data.password
})
it('creates a privileged app-specific password', async () => {
const res = await accntAgent.api.com.atproto.server.createAppPassword({
name: 'privi-pass',
privileged: true,
})
expect(res.data.name).toBe('privi-pass')
expect(res.data.privileged).toBe(true)
privilegedAppPass = res.data.password
})
it('creates a session with an app-specific password', async () => {
const res1 = await appAgent.login({
identifier: 'alice.test',
password: appPass,
})
expect(res1.data.did).toEqual(accntAgent.session?.did)
const res2 = await priviAgent.login({
identifier: 'alice.test',
password: privilegedAppPass,
})
expect(res2.data.did).toEqual(accntAgent.session?.did)
})
it('creates an access token for an app with a restricted scope', () => {
const decoded = jose.decodeJwt(appAgent.session?.accessJwt ?? '')
expect(decoded?.scope).toEqual('com.atproto.appPass')
const decodedPrivi = jose.decodeJwt(priviAgent.session?.accessJwt ?? '')
expect(decodedPrivi?.scope).toEqual('com.atproto.appPassPrivileged')
})
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(),
},
)
await priviAgent.api.app.bsky.feed.post.create(
{
repo: priviAgent.session?.did,
},
{
text: 'testing again',
createdAt: new Date().toISOString(),
},
)
})
it('restricts full access actions', async () => {
const attempt1 = appAgent.api.com.atproto.server.createAppPassword({
name: 'another-one',
})
await expect(attempt1).rejects.toThrow('Bad token scope')
const attempt2 = priviAgent.api.com.atproto.server.createAppPassword({
name: 'another-one',
})
await expect(attempt2).rejects.toThrow('Bad token scope')
})
it('restricts privileged app password actions', async () => {
const attempt = appAgent.api.chat.bsky.convo.listConvos({})
await expect(attempt).rejects.toThrow('Bad token method')
})
it('restricts privileged app password actions', async () => {
const attempt = appAgent.api.chat.bsky.convo.listConvos()
await expect(attempt).rejects.toThrow('Bad token method')
})
it('restricts service auth token methods for non-privileged access tokens', async () => {
const attempt = appAgent.api.com.atproto.server.getServiceAuth({
aud: 'did:example:test',
lxm: 'com.atproto.server.createAccount',
})
await expect(attempt).rejects.toThrow(
/insufficient access to request a service auth token for the following method/,
)
})
it('allows privileged service auth token scopes for privileged access tokens', async () => {
await priviAgent.api.com.atproto.server.getServiceAuth({
aud: 'did:example:test',
lxm: 'com.atproto.server.createAccount',
})
})
it('persists scope across refreshes', async () => {
const session = await appAgent.api.com.atproto.server.refreshSession(
undefined,
{
headers: {
authorization: `Bearer ${appAgent.session?.refreshJwt}`,
},
},
)
// allows any access auth
await appAgent.api.app.bsky.feed.post.create(
{
repo: appAgent.session?.did,
},
{
text: 'Testing testing',
createdAt: new Date().toISOString(),
},
{
authorization: `Bearer ${session.data.accessJwt}`,
},
)
// allows privileged app passwords or higher
const priviAttempt = appAgent.api.com.atproto.server.getServiceAuth({
aud: 'did:example:test',
lxm: 'com.atproto.server.createAccount',
})
await expect(priviAttempt).rejects.toThrow(
/insufficient access to request a service auth token for the following method/,
)
// allows only full access auth
const fullAttempt = appAgent.api.com.atproto.server.createAppPassword(
{
name: 'another-one',
},
{
encoding: 'application/json',
headers: { authorization: `Bearer ${session.data.accessJwt}` },
},
)
await expect(fullAttempt).rejects.toThrow('Bad token scope')
})
it('persists privileged scope across refreshes', async () => {
const session = await priviAgent.api.com.atproto.server.refreshSession(
undefined,
{
headers: {
authorization: `Bearer ${priviAgent.session?.refreshJwt}`,
},
},
)
// allows any access auth
await priviAgent.api.app.bsky.feed.post.create(
{
repo: priviAgent.session?.did,
},
{
text: 'Testing testing',
createdAt: new Date().toISOString(),
},
{
authorization: `Bearer ${session.data.accessJwt}`,
},
)
// allows privileged app passwords or higher
await priviAgent.api.com.atproto.server.getServiceAuth({
aud: 'did:example:test',
})
// allows only full access auth
const attempt = priviAgent.api.com.atproto.server.createAppPassword(
{
name: 'another-one',
},
{
encoding: 'application/json',
headers: { authorization: `Bearer ${session.data.accessJwt}` },
},
)
await expect(attempt).rejects.toThrow('Bad token scope')
})
it('lists available app-specific passwords', async () => {
const res = await appAgent.api.com.atproto.server.listAppPasswords()
expect(res.data.passwords.length).toBe(2)
expect(res.data.passwords[0].name).toEqual('privi-pass')
expect(res.data.passwords[0].privileged).toEqual(true)
expect(res.data.passwords[1].name).toEqual('test-pass')
expect(res.data.passwords[1].privileged).toEqual(false)
})
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 = network.pds.getClient()
const attempt = newAgent.login({
identifier: 'alice.test',
password: appPass,
})
await expect(attempt).rejects.toThrow('Invalid identifier or password')
})
})