Matthieu Sieben 349b59175e
Properly validate auth during refresh (#3847)
* Ensure that the credentials used during a refresh correspond to those used to create the OAuth tokens.

* tidy

* Bind the OAuth session to the kid that was used to authenticate the client (private_key_jwt)

* Store the whole authentication method in the client session store rather than the kid only

* tidy

* Improve error reporting in case an invalid `token_endpoint_auth_method` is used in the client metadata document.

* tidy

* tidy

* Improve JAR checks

* tidy

* changeset

* tidy

* Remove schema's `.optional()` modifier when a `.default()` is defined

* tidy

* verify client auth during code exchange

* tidy

* Minor naming improvement

* tidy

* Update .changeset/quiet-pans-fix.md

Co-authored-by: devin ivy <devinivy@gmail.com>

* Update packages/oauth/oauth-client/src/oauth-client-auth.ts

* Use `private_key_jwt` instead of incorrect `client_secret_jwt` as authentication method for confidential clients

* style

* code split

* dead code removal

* Represent missing client auth with a `null` instead of "none" when storing request data.

* Allow storing `null` in authorization_request's `clientAuth` json column

* document

* tidy

* Remove non-standard behavior that allowed client to authenticate through JAR

* Improved error messages

* Parse JSON encoded Authorization Request Parameters

* Use `application/x-www-form-urlencoded` content instead of JSON for OAuth requests

Fixes: #3723

* tidy

* tidy

* tidy

* tidy

* code style

* remove un-necessary checks

* tidy

* Pre-process number too

* improved type checking

* add missing exports

* fix merge conflict

* tidy

* Remove invalid default for `code_challenge_method` authorization request parameter

* tidy

* Delete inaccurate changeset

* PR comment

* tidy

* Update OAuth client credentials factory to return headers and payload separately.

* tidy

* Renamed `clientAuthCheck` to `validateClientAuth`

* Validate presence of DPoP proofs sooner when processing token requests.

Fixes: #3859

* Protect against concurrent use of request code

* tidy

* tidy

* Update packages/oauth/oauth-provider/src/client/client.ts

Co-authored-by: devin ivy <devinivy@gmail.com>

* Review comments

* Add missing `exp` claim in client attestation JWT

* fixup! Review comments

* Review comments

* Refactor: explicit optionality of unsigned JAR issuer & audience

* Use client attestation's `exp` claim to determine the life time of JWT's `jti` nonce.

* Fix PDS: consumeRequestCode should delete request data

* tidy

* tidy

* Unused code removal

* Restore "Native clients must authenticate using "none" method" check

* tidy

* tidy

* cleanup

* comment

* Allow missing DPoP header during PAR request if `dpop_jkt` is provided

* tidy

---------

Co-authored-by: devin ivy <devinivy@gmail.com>
2025-06-12 15:10:17 +02:00

389 lines
11 KiB
TypeScript

import { SignedJwt, isSignedJwt } from '@atproto/jwk'
import type { Account } from '@atproto/oauth-provider-api'
import {
OAuthAccessToken,
OAuthAuthorizationRequestParameters,
OAuthTokenResponse,
OAuthTokenType,
} from '@atproto/oauth-types'
import { AccessTokenMode } from '../access-token/access-token-mode.js'
import { ClientAuth } from '../client/client-auth.js'
import { Client } from '../client/client.js'
import { TOKEN_MAX_AGE } from '../constants.js'
import { DeviceId } from '../device/device-id.js'
import { InvalidGrantError } from '../errors/invalid-grant-error.js'
import { InvalidRequestError } from '../errors/invalid-request-error.js'
import { InvalidTokenError } from '../errors/invalid-token-error.js'
import { RequestMetadata } from '../lib/http/request.js'
import { dateToEpoch, dateToRelativeSeconds } from '../lib/util/date.js'
import { callAsync } from '../lib/util/function.js'
import { OAuthHooks } from '../oauth-hooks.js'
import { DpopProof } from '../oauth-verifier.js'
import { Sub } from '../oidc/sub.js'
import { Code, isCode } from '../request/code.js'
import { SignedTokenPayload } from '../signer/signed-token-payload.js'
import { Signer } from '../signer/signer.js'
import {
RefreshToken,
generateRefreshToken,
isRefreshToken,
} from './refresh-token.js'
import { TokenData } from './token-data.js'
import { TokenId, generateTokenId, isTokenId } from './token-id.js'
import { TokenInfo, TokenStore } from './token-store.js'
import {
VerifyTokenClaimsOptions,
VerifyTokenClaimsResult,
verifyTokenClaims,
} from './verify-token-claims.js'
export { AccessTokenMode, Signer }
export type { OAuthHooks, TokenStore, VerifyTokenClaimsResult }
export class TokenManager {
constructor(
protected readonly store: TokenStore,
protected readonly signer: Signer,
protected readonly hooks: OAuthHooks,
protected readonly accessTokenMode: AccessTokenMode,
protected readonly tokenMaxAge = TOKEN_MAX_AGE,
) {}
protected createTokenExpiry(now = new Date()) {
return new Date(now.getTime() + this.tokenMaxAge)
}
protected async buildAccessToken(
tokenId: TokenId,
account: Account,
client: Client,
parameters: OAuthAuthorizationRequestParameters,
options: {
now: Date
expiresAt: Date
},
): Promise<OAuthAccessToken> {
return this.signer.createAccessToken({
jti: tokenId,
sub: account.sub,
exp: dateToEpoch(options.expiresAt),
iat: dateToEpoch(options.now),
cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined,
...(this.accessTokenMode === AccessTokenMode.stateless && {
aud: account.aud,
scope: parameters.scope,
// https://datatracker.ietf.org/doc/html/rfc8693#section-4.3
client_id: client.id,
}),
})
}
async createToken(
client: Client,
clientAuth: ClientAuth,
clientMetadata: RequestMetadata,
account: Account,
deviceId: null | DeviceId,
parameters: OAuthAuthorizationRequestParameters,
code: Code,
): Promise<OAuthTokenResponse> {
await this.validateTokenParams(client, clientAuth, parameters)
const tokenId = await generateTokenId()
const refreshToken = client.metadata.grant_types.includes('refresh_token')
? await generateRefreshToken()
: undefined
const now = new Date()
const expiresAt = this.createTokenExpiry(now)
const tokenData: TokenData = {
createdAt: now,
updatedAt: now,
expiresAt,
clientId: client.id,
clientAuth,
deviceId,
sub: account.sub,
parameters,
details: null,
code,
}
const accessToken = await this.buildAccessToken(
tokenId,
account,
client,
parameters,
{ now, expiresAt },
)
const response = await this.buildTokenResponse(
client,
accessToken,
refreshToken,
expiresAt,
parameters,
account.sub,
)
await this.store.createToken(tokenId, tokenData, refreshToken)
try {
await callAsync(this.hooks.onTokenCreated, {
client,
clientAuth,
clientMetadata,
account,
parameters,
})
return response
} catch (err) {
// If the hook fails, we delete the token to avoid leaving a dangling
// token in the store.
await this.deleteToken(tokenId)
throw err
}
}
protected async validateTokenParams(
client: Client,
clientAuth: ClientAuth,
parameters: OAuthAuthorizationRequestParameters,
): Promise<void> {
if (client.metadata.dpop_bound_access_tokens && !parameters.dpop_jkt) {
throw new InvalidGrantError(
`DPoP JKT is required for DPoP bound access tokens`,
)
}
}
protected buildTokenResponse(
client: Client,
accessToken: OAuthAccessToken,
refreshToken: string | undefined,
expiresAt: Date,
parameters: OAuthAuthorizationRequestParameters,
sub: Sub,
): OAuthTokenResponse {
return {
access_token: accessToken,
token_type: parameters.dpop_jkt ? 'DPoP' : 'Bearer',
refresh_token: refreshToken,
scope: parameters.scope,
// @NOTE using a getter so that the value gets computed when the JSON
// response is generated, allowing to value to be as accurate as possible.
get expires_in() {
return dateToRelativeSeconds(expiresAt)
},
// ATPROTO extension: add the sub claim to the token response to allow
// clients to resolve the PDS url (audience) using the did resolution
// mechanism.
sub,
}
}
async rotateToken(
client: Client,
clientAuth: ClientAuth,
clientMetadata: RequestMetadata,
tokenInfo: TokenInfo,
): Promise<OAuthTokenResponse> {
const { account, data } = tokenInfo
const { parameters } = data
await this.validateTokenParams(client, clientAuth, parameters)
const nextTokenId = await generateTokenId()
const nextRefreshToken = await generateRefreshToken()
const now = new Date()
const expiresAt = this.createTokenExpiry(now)
await this.store.rotateToken(tokenInfo.id, nextTokenId, nextRefreshToken, {
updatedAt: now,
expiresAt,
// @NOTE Normally, the clientAuth not change over time. There are two
// exceptions:
// - Upgrade from a legacy representation of client authentication to
// a modern one.
// - Allow clients to become "confidential" if they were previously
// "public"
clientAuth,
})
const accessToken = await this.buildAccessToken(
nextTokenId,
account,
client,
parameters,
{ now, expiresAt },
)
const response = await this.buildTokenResponse(
client,
accessToken,
nextRefreshToken,
expiresAt,
parameters,
account.sub,
)
await callAsync(this.hooks.onTokenRefreshed, {
client,
clientAuth,
clientMetadata,
account,
parameters,
})
return response
}
/**
* @note The token validity is not guaranteed. The caller must ensure that the
* token is valid before using the returned token info.
*/
public async findToken(token: string): Promise<null | TokenInfo> {
if (isTokenId(token)) {
return this.getTokenInfo(token)
} else if (isCode(token)) {
return this.findByCode(token)
} else if (isRefreshToken(token)) {
return this.findByRefreshToken(token)
} else if (isSignedJwt(token)) {
return this.findByAccessToken(token)
} else {
throw new InvalidRequestError(`Invalid token`)
}
}
public async findByAccessToken(token: SignedJwt): Promise<null | TokenInfo> {
const { payload } = await this.signer.verifyAccessToken(token, {
clockTolerance: Infinity,
})
const tokenInfo = await this.getTokenInfo(payload.jti)
if (!tokenInfo) return null
// Fool-proof: Invalid store implementation ?
if (payload.sub !== tokenInfo.account.sub) {
await this.deleteToken(tokenInfo.id)
throw new Error(
`Account sub (${tokenInfo.account.sub}) does not match token sub (${payload.sub})`,
)
}
return tokenInfo
}
protected async findByRefreshToken(
token: RefreshToken,
): Promise<null | TokenInfo> {
return this.store.findTokenByRefreshToken(token)
}
public async consumeRefreshToken(token: RefreshToken): Promise<TokenInfo> {
// @NOTE concurrent refreshes of the same refresh token could theoretically
// lead to two new tokens (access & refresh) being created. This is deemed
// acceptable for now (as the mechanism can only be used once since only one
// of the two refresh token created will be valid, and any future refresh
// attempts from outdated tokens will cause the entire session to be
// invalidated). Ideally, the store should be able to handle this case by
// atomically consuming the refresh token and returning the token info.
// @TODO Add another store method that atomically consumes the refresh token
// with a lock.
const tokenInfo = await this.findByRefreshToken(token).catch((err) => {
throw InvalidTokenError.from(err, `Invalid refresh token`)
})
if (!tokenInfo) {
throw new InvalidGrantError(`Invalid refresh token`)
}
if (tokenInfo.currentRefreshToken !== token) {
await this.deleteToken(tokenInfo.id)
throw new InvalidGrantError(`Refresh token replayed`)
}
return tokenInfo
}
public async findByCode(code: Code): Promise<null | TokenInfo> {
return this.store.findTokenByCode(code)
}
public async deleteToken(tokenId: TokenId): Promise<void> {
return this.store.deleteToken(tokenId)
}
async getTokenInfo(tokenId: TokenId): Promise<null | TokenInfo> {
return this.store.readToken(tokenId)
}
async verifyToken(
token: OAuthAccessToken,
tokenType: OAuthTokenType,
tokenId: TokenId,
dpopProof: null | DpopProof,
verifyOptions?: VerifyTokenClaimsOptions,
): Promise<VerifyTokenClaimsResult> {
const tokenInfo = await this.getTokenInfo(tokenId).catch((err) => {
throw InvalidTokenError.from(err, tokenType)
})
if (!tokenInfo) {
throw new InvalidTokenError(tokenType, `Invalid token`)
}
if (isCurrentTokenExpired(tokenInfo)) {
await this.deleteToken(tokenId)
throw new InvalidTokenError(tokenType, `Token expired`)
}
const { account, data } = tokenInfo
const { parameters } = data
// Construct a list of claim, as if the token was a JWT.
const tokenClaims: SignedTokenPayload = {
iss: this.signer.issuer,
jti: tokenId,
sub: account.sub,
exp: dateToEpoch(data.expiresAt),
iat: dateToEpoch(data.updatedAt),
cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined,
// These are not stored in the JWT access token in "light" access token
// mode. See `buildAccessToken`.
aud: account.aud,
scope: parameters.scope,
client_id: data.clientId,
}
return verifyTokenClaims(
token,
tokenId,
tokenType,
tokenClaims,
dpopProof,
verifyOptions,
)
}
async listAccountTokens(sub: Sub): Promise<TokenInfo[]> {
const results = await this.store.listAccountTokens(sub)
return results
.filter((tokenInfo) => tokenInfo.account.sub === sub) // Fool proof
.filter((tokenInfo) => !isCurrentTokenExpired(tokenInfo))
}
}
function isCurrentTokenExpired(tokenInfo: TokenInfo): boolean {
return tokenInfo.data.expiresAt.getTime() < Date.now()
}