Send an integer exp for video upload service auth tokens (#11529)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-25 03:34:16 +03:00
committed by GitHub
parent 95f5360012
commit cf2684b0d1
5 changed files with 121 additions and 7 deletions
@@ -0,0 +1,70 @@
import {type Client} from '@atproto/lex'
import {com} from '#/lexicons'
import {
getServiceAuthToken,
SERVICE_AUTH_TTL_SEC,
serviceAuthExp,
} from '../upload.shared'
function createClient() {
const call = jest.fn().mockResolvedValue({token: 'token'})
return {client: {call} as unknown as Client, call}
}
describe('serviceAuthExp', () => {
it('returns an integer even when the clock has sub-second precision', () => {
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_500)
expect(serviceAuthExp()).toBe(1_700_000_000 + SERVICE_AUTH_TTL_SEC)
expect(Number.isInteger(serviceAuthExp())).toBe(true)
})
it('accepts a custom ttl and keeps the result integral', () => {
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_999)
expect(serviceAuthExp(90.7)).toBe(1_700_000_090)
})
})
describe('getServiceAuthToken', () => {
it('floors a fractional exp before sending it', async () => {
const {client, call} = createClient()
await getServiceAuthToken({
client,
aud: 'did:web:video.bsky.app',
lxm: 'com.atproto.repo.uploadBlob',
exp: 1_700_001_800.5,
})
expect(call).toHaveBeenCalledWith(com.atproto.server.getServiceAuth, {
aud: 'did:web:video.bsky.app',
lxm: 'com.atproto.repo.uploadBlob',
exp: 1_700_001_800,
})
})
it('leaves exp undefined when the caller omits it', async () => {
const {client, call} = createClient()
await getServiceAuthToken({
client,
aud: 'did:web:video.bsky.app',
lxm: 'app.bsky.video.getUploadLimits',
})
expect(call).toHaveBeenCalledWith(com.atproto.server.getServiceAuth, {
aud: 'did:web:video.bsky.app',
lxm: 'app.bsky.video.getUploadLimits',
exp: undefined,
})
})
it('rejects a non-finite exp rather than sending NaN', async () => {
const {client, call} = createClient()
await expect(
getServiceAuthToken({
client,
aud: 'did:web:video.bsky.app',
lxm: 'com.atproto.repo.uploadBlob',
exp: NaN,
}),
).rejects.toThrow('Invalid service auth exp')
expect(call).not.toHaveBeenCalled()
})
})
+2 -2
View File
@@ -5,7 +5,7 @@ import {AbortError} from '#/lib/async/cancelable'
import {type CompressedVideo} from '#/lib/media/video/types'
import {shouldRetryError} from '#/lib/strings/errors'
import {type app} from '#/lexicons'
import {getServiceAuthToken} from '../upload.shared'
import {getServiceAuthToken, serviceAuthExp} from '../upload.shared'
import {mimeToExt} from '../util'
import {
abortUpload,
@@ -280,7 +280,7 @@ function createTokenProvider(
async function get(forceRefresh = false) {
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
if (!refresh) {
const exp = Math.floor(Date.now() / 1000) + 60 * 30
const exp = serviceAuthExp()
refresh = getServiceAuthTokenWithRetry(client, dispatchUrl, exp, signal)
.then(nextToken => {
token = nextToken
+37 -1
View File
@@ -27,6 +27,11 @@ export async function getServiceAuthToken({
dispatchUrl?: string | URL
aud?: string
lxm: NsidString
/**
* Unix timestamp in *seconds* at which the token expires. Fractional values
* are floored - see {@link toIntegerExp}. Defaults to the server's own
* short expiry when omitted.
*/
exp?: number
}) {
let resolvedAud = aud
@@ -43,11 +48,42 @@ export async function getServiceAuthToken({
const {token} = await client.call(com.atproto.server.getServiceAuth, {
aud: resolvedAud as DidString,
lxm,
exp,
exp: exp === undefined ? undefined : toIntegerExp(exp),
})
return token
}
/**
* Default lifetime for the video upload service auth token. Long enough to
* cover a slow upload of a large file, short enough to limit the damage if the
* token leaks.
*/
export const SERVICE_AUTH_TTL_SEC = 60 * 30
/**
* Build a service auth `exp` claim `ttlSec` seconds from now.
*
* Always use this instead of hand-rolling the arithmetic: `Date.now()` is in
* milliseconds, and dividing by 1000 without flooring yields a fractional
* timestamp that the endpoint rejects.
*/
export function serviceAuthExp(ttlSec: number = SERVICE_AUTH_TTL_SEC) {
return Math.floor(Date.now() / 1000) + Math.floor(ttlSec)
}
/**
* The lexicon types `exp` as an integer and it is serialized straight into the
* query string, so a fractional value fails validation and the upload dies
* before it starts. Floor here, at the single chokepoint every caller goes
* through, so a call site that forgets to cannot reintroduce the bug.
*/
function toIntegerExp(exp: number) {
if (!Number.isFinite(exp)) {
throw new Error(`Invalid service auth exp: ${exp}`)
}
return Math.floor(exp)
}
export async function getVideoUploadLimits(client: Client, i18n: I18n) {
const token = await getServiceAuthToken({
client,
+6 -2
View File
@@ -13,7 +13,11 @@ import {
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {
getServiceAuthToken,
getVideoUploadLimits,
serviceAuthExp,
} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
@@ -72,7 +76,7 @@ export async function uploadVideo({
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
exp: serviceAuthExp(),
})
const uploadTask = createUploadTask(
uri,
+6 -2
View File
@@ -12,7 +12,11 @@ import {
import {Features, features} from '#/analytics/features'
import {type app} from '#/lexicons'
import {MultipartFallbackError, uploadVideoMultipart} from './multipart/upload'
import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared'
import {
getServiceAuthToken,
getVideoUploadLimits,
serviceAuthExp,
} from './upload.shared'
import {createVideoEndpointUrl, mimeToExt} from './util'
export async function uploadVideo({
@@ -79,7 +83,7 @@ export async function uploadVideo({
client,
dispatchUrl,
lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
exp: serviceAuthExp(),
})
if (signal.aborted) {