574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import { z } from 'zod';
|
|
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
|
|
import type { GiteaGrantProvider } from './grant.js';
|
|
import type {
|
|
GiteaTeamGrantProvider,
|
|
PresenceEvidence,
|
|
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 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) {
|
|
const bytes = Number.parseInt(declared, 10);
|
|
if (Number.isFinite(bytes) && bytes > MAX_PROVIDER_BYTES) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response exceeded the bounded size',
|
|
);
|
|
}
|
|
}
|
|
const body = new Uint8Array(await response.arrayBuffer());
|
|
if (body.byteLength > MAX_PROVIDER_BYTES) {
|
|
throw new CredentialProviderEvidenceError(
|
|
'unexpected-provider-shape',
|
|
'provider response exceeded the bounded size',
|
|
);
|
|
}
|
|
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';
|
|
return 'read';
|
|
}
|
|
|
|
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
|
protected readonly origin: string;
|
|
|
|
constructor(
|
|
apiBaseUrl: string,
|
|
private readonly fetchImpl: FetchLike = fetch,
|
|
) {
|
|
const parsed = new URL(apiBaseUrl);
|
|
this.origin = parsed.origin;
|
|
}
|
|
|
|
protected async request(url: string, init: RequestInit): Promise<Response> {
|
|
try {
|
|
return await this.fetchImpl(url, init);
|
|
} 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 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)}`,
|
|
);
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
private async readPresence(
|
|
authority: ResolvedCredential,
|
|
endpoint: string,
|
|
): 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');
|
|
}
|
|
await boundedBody(response);
|
|
return { state: 'present', endpoint, contentType: contentType(response) };
|
|
}
|
|
}
|