833 lines
26 KiB
TypeScript
833 lines
26 KiB
TypeScript
import { z } from 'zod';
|
|
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
|
|
import type { GiteaGrantProvider } from './grant.js';
|
|
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
|
|
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
|
|
import type {
|
|
GiteaTeamGrantProvider,
|
|
PresenceEvidence,
|
|
TeamRepositorySetEvidence,
|
|
TeamResolutionEvidence,
|
|
} from './team-grant.js';
|
|
import type {
|
|
CollaboratorPermissionEvidenceDto,
|
|
OrganizationMembershipEvidenceDto,
|
|
} from './grant.dto.js';
|
|
import type {
|
|
ProviderIdentityEvidenceDto,
|
|
ReceivePackEvidenceDto,
|
|
RepositoryPermission,
|
|
RepositoryPermissionEvidenceDto,
|
|
} from './credential-result.dto.js';
|
|
|
|
const MAX_PROVIDER_BYTES = 1024 * 1024;
|
|
const USER_AGENT = 'mosaic-cred/1';
|
|
const JSON_CONTENT_TYPE = 'application/json';
|
|
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
|
const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/;
|
|
|
|
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
|
|
const userSchema = z
|
|
.object({
|
|
id: z.number().int(),
|
|
login: z.string().min(1),
|
|
is_admin: z.boolean().optional(),
|
|
visibility: z.enum(['public', 'limited', 'private']).optional(),
|
|
})
|
|
.passthrough();
|
|
|
|
const collaboratorPermissionSchema = z
|
|
.object({
|
|
permission: z.enum(['read', 'write', 'admin']),
|
|
user: z.object({ login: z.string().min(1) }).passthrough(),
|
|
})
|
|
.passthrough();
|
|
|
|
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
|
|
const tokenObjectSchema = z
|
|
.object({
|
|
name: z.string().min(1),
|
|
sha1: z.string().min(1).optional(),
|
|
token: z.string().min(1).optional(),
|
|
scopes: z.array(z.string()).default([]),
|
|
})
|
|
.passthrough();
|
|
const teamSchema = z
|
|
.object({
|
|
id: z.number().int().positive(),
|
|
name: z.string().min(1),
|
|
permission: z.enum(['read', 'write', 'admin']),
|
|
})
|
|
.passthrough();
|
|
|
|
const repoSchema = z
|
|
.object({
|
|
id: z.number().int(),
|
|
full_name: z.string().min(3),
|
|
permissions: z
|
|
.object({
|
|
admin: z.boolean(),
|
|
push: z.boolean(),
|
|
pull: z.boolean(),
|
|
})
|
|
.strict(),
|
|
})
|
|
.passthrough();
|
|
|
|
export class CredentialProviderEvidenceError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(`Gitea credential evidence unavailable: code=${code} ${message}`);
|
|
this.name = 'CredentialProviderEvidenceError';
|
|
}
|
|
}
|
|
|
|
function contentType(response: Response): string {
|
|
return response.headers.get('content-type') ?? '';
|
|
}
|
|
|
|
function isJson(response: Response): boolean {
|
|
return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
|
}
|
|
|
|
async function boundedBody(response: Response): Promise<Uint8Array> {
|
|
const declared = response.headers.get('content-length');
|
|
if (declared !== null) {
|
|
if (!/^\d+$/.test(declared)) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response declared an invalid content length',
|
|
);
|
|
}
|
|
const bytes = Number(declared);
|
|
if (!Number.isSafeInteger(bytes) || bytes > MAX_PROVIDER_BYTES) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response exceeded the bounded size',
|
|
);
|
|
}
|
|
}
|
|
if (response.body === null) return new Uint8Array();
|
|
const reader = response.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
try {
|
|
for (;;) {
|
|
const next = await reader.read();
|
|
if (next.done) break;
|
|
total += next.value.byteLength;
|
|
if (total > MAX_PROVIDER_BYTES) {
|
|
await reader.cancel();
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response exceeded the bounded size',
|
|
);
|
|
}
|
|
chunks.push(next.value);
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
if (declared !== null && total !== Number(declared)) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response length contradicted its declaration',
|
|
);
|
|
}
|
|
const body = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
body.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function jsonObject(response: Response): Promise<unknown> {
|
|
if (!isJson(response)) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-content-type',
|
|
'provider response was not JSON',
|
|
);
|
|
}
|
|
const bytes = await boundedBody(response);
|
|
try {
|
|
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
} catch {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider JSON could not be parsed',
|
|
);
|
|
}
|
|
}
|
|
|
|
function tokenText(resolved: ResolvedCredential): string {
|
|
try {
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret);
|
|
} catch {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'credential bytes were not valid text',
|
|
);
|
|
}
|
|
}
|
|
|
|
function apiAuthorization(resolved: ResolvedCredential): string {
|
|
return `token ${tokenText(resolved)}`;
|
|
}
|
|
|
|
function gitAuthorization(resolved: ResolvedCredential): string {
|
|
const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString(
|
|
'base64',
|
|
);
|
|
return `Basic ${basic}`;
|
|
}
|
|
|
|
function repoPath(repo: string): { readonly owner: string; readonly name: string } {
|
|
const pieces = repo.split('/');
|
|
const owner = pieces[0];
|
|
const name = pieces[1];
|
|
if (
|
|
pieces.length !== 2 ||
|
|
owner === undefined ||
|
|
name === undefined ||
|
|
!REPO_COMPONENT.test(owner) ||
|
|
!REPO_COMPONENT.test(name)
|
|
) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'repository must be exactly owner/name in the allowlist grammar',
|
|
);
|
|
}
|
|
return { owner, name };
|
|
}
|
|
|
|
function effectivePermission(permissions: {
|
|
readonly admin: boolean;
|
|
readonly push: boolean;
|
|
readonly pull: boolean;
|
|
}): RepositoryPermission {
|
|
if (permissions.admin) return 'admin';
|
|
if (permissions.push) return 'write';
|
|
if (permissions.pull) return 'read';
|
|
return 'none';
|
|
}
|
|
|
|
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
|
protected readonly origin: string;
|
|
|
|
constructor(
|
|
apiBaseUrl: string,
|
|
private readonly fetchImpl: FetchLike = fetch,
|
|
private readonly requestTimeoutMs = 10_000,
|
|
) {
|
|
const parsed = new URL(apiBaseUrl);
|
|
this.origin = parsed.origin;
|
|
if (
|
|
!Number.isSafeInteger(requestTimeoutMs) ||
|
|
requestTimeoutMs < 1 ||
|
|
requestTimeoutMs > 30_000
|
|
) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'invalid-input',
|
|
'provider request timeout is outside the bounded range',
|
|
);
|
|
}
|
|
}
|
|
|
|
protected async request(url: string, init: RequestInit): Promise<Response> {
|
|
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
|
|
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
|
|
try {
|
|
return await this.fetchImpl(url, { ...init, signal });
|
|
} catch {
|
|
throw new CredentialProviderEvidenceError(
|
|
'provider-unavailable',
|
|
'provider request failed before evidence was available',
|
|
);
|
|
}
|
|
}
|
|
|
|
private async classifyRejectedIdentity(rejected: Response): Promise<never> {
|
|
if (!isJson(rejected)) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-content-type',
|
|
'provider credential rejection was not JSON',
|
|
);
|
|
}
|
|
const status = rejected.status;
|
|
await boundedBody(rejected);
|
|
if (status === 403 || status === 404) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'identity-read-forbidden',
|
|
'provider denied the identity endpoint; credential capability must be tested in scope',
|
|
);
|
|
}
|
|
throw new CredentialProviderEvidenceError(
|
|
'credential-rejected',
|
|
'provider rejected the supplied credential; account existence was not inferred',
|
|
);
|
|
}
|
|
|
|
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
|
const endpoint = 'GET /api/v1/user';
|
|
const response = await this.request(`${this.origin}/api/v1/user`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(resolved),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
return this.classifyRejectedIdentity(response);
|
|
}
|
|
if (!response.ok) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'provider-unavailable',
|
|
`provider identity request returned HTTP ${response.status.toString()}`,
|
|
);
|
|
}
|
|
const parsed = userSchema.safeParse(await jsonObject(response));
|
|
if (!parsed.success) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider identity object lacked required fields',
|
|
);
|
|
}
|
|
return {
|
|
login: parsed.data.login,
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
};
|
|
}
|
|
|
|
async readRepositoryPermission(
|
|
resolved: ResolvedCredential,
|
|
repo: string,
|
|
): Promise<RepositoryPermissionEvidenceDto> {
|
|
const { owner, name } = repoPath(repo);
|
|
const endpoint = `GET /api/v1/repos/${owner}/${name}`;
|
|
const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(resolved),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'provider-unavailable',
|
|
`provider repository request returned HTTP ${response.status.toString()}`,
|
|
);
|
|
}
|
|
const parsed = repoSchema.safeParse(await jsonObject(response));
|
|
if (!parsed.success || parsed.data.full_name !== repo) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider repository object did not identify the requested repository',
|
|
);
|
|
}
|
|
return {
|
|
effective: effectivePermission(parsed.data.permissions),
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
};
|
|
}
|
|
|
|
async probeReceivePack(
|
|
resolved: ResolvedCredential | undefined,
|
|
repo: string,
|
|
): Promise<ReceivePackEvidenceDto> {
|
|
const { owner, name } = repoPath(repo);
|
|
const headers = new Headers({
|
|
Accept: RECEIVE_PACK_CONTENT_TYPE,
|
|
'User-Agent': USER_AGENT,
|
|
});
|
|
if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved));
|
|
const response = await this.request(
|
|
`${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`,
|
|
{ method: 'GET', headers },
|
|
);
|
|
const responseType = contentType(response);
|
|
if (response.status === 401 || response.status === 403) {
|
|
await boundedBody(response);
|
|
return {
|
|
state: 'refused',
|
|
principal: resolved?.identity ?? null,
|
|
resolutionId: resolved?.resolutionId ?? null,
|
|
contentType: responseType,
|
|
};
|
|
}
|
|
if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) {
|
|
throw new CredentialProviderEvidenceError(
|
|
response.ok ? 'unexpected-content-type' : 'provider-unavailable',
|
|
`receive-pack response was not an advertisement (HTTP ${response.status.toString()})`,
|
|
);
|
|
}
|
|
const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response));
|
|
if (!body.includes('# service=git-receive-pack')) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'receive-pack advertisement lacked the protocol service preamble',
|
|
);
|
|
}
|
|
return {
|
|
state: 'advertised',
|
|
principal: resolved?.identity ?? null,
|
|
resolutionId: resolved?.resolutionId ?? null,
|
|
contentType: responseType,
|
|
};
|
|
}
|
|
}
|
|
|
|
export class GiteaGrantProviderAdapter
|
|
extends GiteaCredentialProviderAdapter
|
|
implements GiteaGrantProvider
|
|
{
|
|
async grantCollaborator(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
repo: string,
|
|
permission: RepositoryPermission,
|
|
): Promise<void> {
|
|
const { owner, name } = repoPath(repo);
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'Content-Type': JSON_CONTENT_TYPE,
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
body: JSON.stringify({ permission }),
|
|
},
|
|
);
|
|
await boundedBody(response);
|
|
if (!response.ok) {
|
|
throw new CredentialProviderEvidenceError(
|
|
response.status === 401 || response.status === 403
|
|
? 'credential-rejected'
|
|
: 'provider-unavailable',
|
|
`provider grant request returned HTTP ${response.status.toString()}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async readCollaboratorPermission(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
repo: string,
|
|
): Promise<CollaboratorPermissionEvidenceDto> {
|
|
const { owner, name } = repoPath(repo);
|
|
const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`;
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError(
|
|
'readback-missing',
|
|
`collaborator permission read-back returned HTTP ${response.status.toString()}`,
|
|
);
|
|
}
|
|
const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response));
|
|
if (!parsed.success || parsed.data.user.login !== identity) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'collaborator permission object did not identify the declared subject',
|
|
);
|
|
}
|
|
return {
|
|
identity: parsed.data.user.login,
|
|
permission: parsed.data.permission,
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
};
|
|
}
|
|
|
|
async readOrganizationMembership(
|
|
subject: ResolvedCredential,
|
|
organization: string,
|
|
): Promise<OrganizationMembershipEvidenceDto> {
|
|
const endpoint = `GET /api/v1/users/${subject.identity}/orgs`;
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(subject),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError(
|
|
response.status === 401 || response.status === 403
|
|
? 'scope-not-evaluable'
|
|
: 'provider-unavailable',
|
|
`organization membership read-back returned HTTP ${response.status.toString()}`,
|
|
);
|
|
}
|
|
const parsed = z.array(organizationSchema).safeParse(await jsonObject(response));
|
|
if (!parsed.success) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'organization membership response was not an organization array',
|
|
);
|
|
}
|
|
return {
|
|
state: parsed.data.some((entry): boolean => entry.username === organization)
|
|
? 'present'
|
|
: 'absent',
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class GiteaTeamGrantProviderAdapter
|
|
extends GiteaGrantProviderAdapter
|
|
implements GiteaTeamGrantProvider
|
|
{
|
|
async resolveTeam(
|
|
authority: ResolvedCredential,
|
|
organization: string,
|
|
team: string,
|
|
): Promise<TeamResolutionEvidence> {
|
|
const endpoint = `GET /api/v1/orgs/${organization}/teams`;
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError(
|
|
'provider-unavailable',
|
|
'team list was unavailable',
|
|
);
|
|
}
|
|
const parsed = z.array(teamSchema).safeParse(await jsonObject(response));
|
|
const matches = parsed.success
|
|
? parsed.data.filter((entry): boolean => entry.name === team)
|
|
: [];
|
|
if (matches.length !== 1 || matches[0] === undefined) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'readback-missing',
|
|
'team did not resolve uniquely',
|
|
);
|
|
}
|
|
return { ...matches[0], endpoint, contentType: contentType(response) };
|
|
}
|
|
|
|
async listTeamRepositories(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
): Promise<TeamRepositorySetEvidence> {
|
|
const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`;
|
|
const repositories: string[] = [];
|
|
let observedType = '';
|
|
for (let page = 1; page <= 100; page += 1) {
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError(
|
|
'readback-missing',
|
|
'team repository set was unavailable',
|
|
);
|
|
}
|
|
observedType = contentType(response);
|
|
const parsed = z.array(repoSchema).safeParse(await jsonObject(response));
|
|
if (!parsed.success) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'team repository set was not a repository array',
|
|
);
|
|
}
|
|
repositories.push(...parsed.data.map((repo): string => repo.full_name));
|
|
if (parsed.data.length < 50) {
|
|
return { repositories, endpoint, contentType: observedType };
|
|
}
|
|
}
|
|
throw new CredentialProviderEvidenceError(
|
|
'readback-missing',
|
|
'team repository set exceeded the pagination bound',
|
|
);
|
|
}
|
|
|
|
async addTeamMember(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
identity: string,
|
|
): Promise<void> {
|
|
await this.putTeamPath(
|
|
authority,
|
|
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
|
);
|
|
}
|
|
|
|
async attachTeamRepository(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
repo: string,
|
|
): Promise<void> {
|
|
const { owner, name } = repoPath(repo);
|
|
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
|
}
|
|
|
|
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
|
const response = await this.request(`${this.origin}${path}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
await boundedBody(response);
|
|
if (!response.ok) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'provider-unavailable',
|
|
'team grant mutation failed',
|
|
);
|
|
}
|
|
}
|
|
|
|
async readTeamMember(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
identity: string,
|
|
): Promise<PresenceEvidence> {
|
|
return this.readPresence(
|
|
authority,
|
|
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
|
(value: unknown): boolean => {
|
|
const parsed = userSchema.safeParse(value);
|
|
return parsed.success && parsed.data.login === identity;
|
|
},
|
|
);
|
|
}
|
|
|
|
async readTeamRepository(
|
|
authority: ResolvedCredential,
|
|
teamId: number,
|
|
repo: string,
|
|
): Promise<PresenceEvidence> {
|
|
const { owner, name } = repoPath(repo);
|
|
return this.readPresence(
|
|
authority,
|
|
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
|
(value: unknown): boolean => {
|
|
const parsed = repoSchema.safeParse(value);
|
|
return parsed.success && parsed.data.full_name === repo;
|
|
},
|
|
);
|
|
}
|
|
|
|
private async readPresence(
|
|
authority: ResolvedCredential,
|
|
endpoint: string,
|
|
matchesExpectedObject: (value: unknown) => boolean,
|
|
): Promise<PresenceEvidence> {
|
|
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: apiAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
if (response.status === 404) {
|
|
await boundedBody(response);
|
|
return { state: 'absent', endpoint, contentType: contentType(response) };
|
|
}
|
|
if (!response.ok || !isJson(response)) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
|
|
}
|
|
if (!matchesExpectedObject(await jsonObject(response))) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'team read-back did not identify the requested object',
|
|
);
|
|
}
|
|
return { state: 'present', endpoint, contentType: contentType(response) };
|
|
}
|
|
}
|
|
|
|
function basicAuthorization(authority: ResolvedCredential): string {
|
|
const prefix = Buffer.from(`${authority.identity}:`, 'utf8');
|
|
const material = Buffer.concat([prefix, Buffer.from(authority.secret)]);
|
|
try {
|
|
return `Basic ${material.toString('base64')}`;
|
|
} finally {
|
|
prefix.fill(0);
|
|
material.fill(0);
|
|
}
|
|
}
|
|
|
|
export class GiteaLifecycleProviderAdapter
|
|
extends GiteaCredentialProviderAdapter
|
|
implements GiteaLifecycleProvider
|
|
{
|
|
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
|
const endpoint = 'GET /api/v1/user';
|
|
const response = await this.request(`${this.origin}/api/v1/user`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: basicAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError(
|
|
'credential-rejected',
|
|
'delegated Basic authority was rejected',
|
|
);
|
|
}
|
|
const parsed = userSchema.safeParse(await jsonObject(response));
|
|
if (!parsed.success) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'delegated Basic identity response was malformed',
|
|
);
|
|
}
|
|
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
|
}
|
|
|
|
async mintToken(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
name: string,
|
|
scopes: readonly string[],
|
|
): Promise<MintedToken> {
|
|
const endpoint = `POST /api/v1/users/${identity}/tokens`;
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
'Content-Type': JSON_CONTENT_TYPE,
|
|
Authorization: basicAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
body: JSON.stringify({ name, scopes }),
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed');
|
|
}
|
|
const parsed = tokenObjectSchema.safeParse(await jsonObject(response));
|
|
const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined;
|
|
if (!parsed.success || secret === undefined || parsed.data.name !== name) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'minted token object lacked the requested name or secret',
|
|
);
|
|
}
|
|
return {
|
|
secret: new TextEncoder().encode(secret),
|
|
evidence: {
|
|
name: parsed.data.name,
|
|
scopes: parsed.data.scopes,
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
},
|
|
};
|
|
}
|
|
|
|
async readToken(
|
|
authority: ResolvedCredential,
|
|
identity: string,
|
|
name: string,
|
|
): Promise<TokenObjectEvidenceDto> {
|
|
const endpoint = `GET /api/v1/users/${identity}/tokens`;
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: basicAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
await boundedBody(response);
|
|
throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed');
|
|
}
|
|
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
|
|
const matches = parsed.success
|
|
? parsed.data.filter((token): boolean => token.name === name)
|
|
: [];
|
|
if (matches.length !== 1 || matches[0] === undefined) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'readback-missing',
|
|
'minted token did not resolve uniquely by name',
|
|
);
|
|
}
|
|
return {
|
|
name: matches[0].name,
|
|
scopes: matches[0].scopes,
|
|
endpoint,
|
|
contentType: contentType(response),
|
|
};
|
|
}
|
|
|
|
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
|
|
const response = await this.request(
|
|
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: JSON_CONTENT_TYPE,
|
|
Authorization: basicAuthorization(authority),
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
},
|
|
);
|
|
await boundedBody(response);
|
|
if (!response.ok) {
|
|
throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed');
|
|
}
|
|
}
|
|
}
|