Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions src/core/external-authorization-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ export type ExternalProviderAuthorization = {
authorizationDetailsTypes?: readonly string[]
authorizationDetailsCatalog?: {
scope: string
list(input: { subject: string; limit: number; offset: number }): Promise<{
list(input: { subject: string; page: number; pageSize: number }): Promise<{
items: Array<{
authorizationDetail: Record<string, unknown>
grantedScopes?: string[]
display: { label: string; description?: string; metadata?: Record<string, string> }
}>
pagination: { limit: number; offset: number; total: number; hasMore: boolean; nextOffset: number | null }
pagination: { page: number; pageSize: number; totalItems: number; totalPages: number }
}>
}
authorizationDetailsSubset?(input: {
Expand Down Expand Up @@ -410,13 +410,14 @@ export async function createExternalAuthorizationServer(input: {
if (!scopes.includes(authorizationDetailsCatalog.scope)) {
throw oauthError('insufficient_scope', 'The access token does not authorize catalog discovery.', 403)
}
return c.json(
await authorizationDetailsCatalog.list({
subject: String(verified.payload.sub),
limit: paginationInteger(c.req.query('limit'), 'limit', 50, 1),
offset: paginationInteger(c.req.query('offset'), 'offset', 0, 0),
}),
)
const result = await authorizationDetailsCatalog.list({
subject: String(verified.payload.sub),
page: paginationInteger(c.req.query('page'), 'page', 1, 1),
pageSize: paginationInteger(c.req.query('pageSize'), 'pageSize', 20, 1),
})
const link = paginationLinkHeader(c.req.url, result.pagination)
if (link) c.header('Link', link)
return c.json(result)
})
}
},
Expand Down Expand Up @@ -684,12 +685,33 @@ function normalizeScopes(value: string) {
function paginationInteger(value: string | undefined, name: string, fallback: number, minimum: number) {
if (value === undefined) return fallback
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < minimum || (name === 'limit' && parsed > 100)) {
if (!Number.isSafeInteger(parsed) || parsed < minimum || (name === 'pageSize' && parsed > 100)) {
throw oauthError('invalid_request', `${name} is invalid.`)
}
return parsed
}

function paginationLinkHeader(requestUrl: string, pagination: { page: number; pageSize: number; totalPages: number }) {
if (pagination.totalPages === 0) return null
const links: string[] = []
if (pagination.page > 1) {
links.push(paginationLink(requestUrl, 1, pagination.pageSize, 'first'))
links.push(paginationLink(requestUrl, pagination.page - 1, pagination.pageSize, 'previous'))
}
if (pagination.page < pagination.totalPages) {
links.push(paginationLink(requestUrl, pagination.page + 1, pagination.pageSize, 'next'))
links.push(paginationLink(requestUrl, pagination.totalPages, pagination.pageSize, 'last'))
}
return links.length ? links.join(', ') : null
}

function paginationLink(requestUrl: string, page: number, pageSize: number, relation: string) {
const url = new URL(requestUrl)
url.searchParams.set('page', String(page))
url.searchParams.set('pageSize', String(pageSize))
return `<${url.toString()}>; rel="${relation}"`
}

function validRedirectUri(value: string) {
try {
const url = new URL(value)
Expand Down
15 changes: 7 additions & 8 deletions src/providers/github/external-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,21 @@ export function createGitHubExternalAuthorization(input: {
authorizationDetailsTypes: [GITHUB_INSTALLATION_AUTHORIZATION_DETAIL_TYPE],
authorizationDetailsCatalog: {
scope: authorizationDetailsCatalogScope,
async list({ subject, limit, offset }) {
async list({ subject, page, pageSize }) {
const contexts = (await input.connections.externalAuthorization(subject)).contexts
const items = contexts.slice(offset, offset + limit).map((context) => ({
const offset = (page - 1) * pageSize
const items = contexts.slice(offset, offset + pageSize).map((context) => ({
authorizationDetail: githubInstallationAuthorizationDetail(context),
grantedScopes: context.scopes,
display: githubInstallationAuthorizationDetailDisplay(context),
}))
const nextOffset = offset + limit < contexts.length ? offset + limit : null
return {
items,
pagination: {
limit,
offset,
total: contexts.length,
hasMore: nextOffset !== null,
nextOffset,
page,
pageSize,
totalItems: contexts.length,
totalPages: Math.ceil(contexts.length / pageSize),
},
}
},
Expand Down
16 changes: 10 additions & 6 deletions test/core/external-authorization-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe('external authorization server', () => {
expect(tokenResponse.status).toBe(200)
const token = (await tokenResponse.json()) as { access_token: string }

const response = await app.request('/oauth/example/authorization-details?limit=10&offset=0', {
const response = await app.request('/oauth/example/authorization-details?page=2&pageSize=1', {
headers: { authorization: `Bearer ${token.access_token}` },
})

Expand All @@ -65,12 +65,16 @@ describe('external authorization server', () => {
display: { label: 'Project One' },
},
],
pagination: { limit: 10, offset: 0, total: 1, hasMore: false, nextOffset: null },
pagination: { page: 2, pageSize: 1, totalItems: 2, totalPages: 2 },
})
expect(response.headers.get('link')).toBe(
'<http://localhost/oauth/example/authorization-details?page=1&pageSize=1>; rel="first", ' +
'<http://localhost/oauth/example/authorization-details?page=1&pageSize=1>; rel="previous"',
)
expect(provider.authorizationDetailsCatalog?.list).toHaveBeenCalledWith({
subject: 'provider-user-1',
limit: 10,
offset: 0,
page: 2,
pageSize: 1,
})
})

Expand Down Expand Up @@ -289,14 +293,14 @@ async function testServer(
authorizationDetailsTypes: ['example_context'],
authorizationDetailsCatalog: {
scope: 'authorization-details:read',
list: vi.fn(async ({ limit, offset }) => ({
list: vi.fn(async ({ page, pageSize }) => ({
items: [
{
authorizationDetail: { type: 'example_context', project_id: 'project-1' },
display: { label: 'Project One' },
},
],
pagination: { limit, offset, total: 1, hasMore: false, nextOffset: null },
pagination: { page, pageSize, totalItems: 2, totalPages: 2 },
})),
},
begin: vi.fn(({ providerState }) => ({
Expand Down
4 changes: 2 additions & 2 deletions test/providers/github-external-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('GitHub external authorization', () => {
})

await expect(
external.authorization.authorizationDetailsCatalog?.list({ subject: '70', limit: 10, offset: 0 }),
external.authorization.authorizationDetailsCatalog?.list({ subject: '70', page: 1, pageSize: 10 }),
).resolves.toEqual({
items: [
{
Expand All @@ -52,7 +52,7 @@ describe('GitHub external authorization', () => {
},
},
],
pagination: { limit: 10, offset: 0, total: 1, hasMore: false, nextOffset: null },
pagination: { page: 1, pageSize: 10, totalItems: 1, totalPages: 1 },
})
})

Expand Down