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>
253 lines
7.5 KiB
TypeScript
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')
|
|
})
|
|
})
|