Authorize Git clients
Enroute identifies repositories by opaque keys and does not manage users. This chapter maps a Git URL to a repository and authenticates the caller.
At the end of this chapter, token-authenticated clients can clone a repository.
What authorize is for
authorize runs once per Git request, before Enroute serves data. It carries
the request headers and path so the application can apply its credential and
path rules.
| Field | What it holds |
|---|---|
repoPath | The path the client sent, at any depth, with .git if the client sent one |
headers | The request headers as they arrived, credential included |
access | ACCESS_READ for a fetch, ACCESS_WRITE for a push |
You answer granted with a repository key and an actor, or denied with a
reason. See authorize for every field.
Read the credential
Git sends Basic authentication by default, with the token as the password and a placeholder username that each client picks for itself. Accept both spellings and ignore the username.
Create lib/hooks/authorize.ts:
import type { Header } from "@/lib/gen/enroute/hook/v1alpha1/hook";
export function tokenFrom(headers: Header[]): string | undefined {
const auth = headers.find((h) => h.name.toLowerCase() === "authorization");
if (!auth) return undefined;
const bearer = auth.value.match(/^Bearer (.+)$/i);
if (bearer) return bearer[1];
const basic = auth.value.match(/^Basic (.+)$/i);
if (!basic) return undefined;
// Git needs some username before it sends a password, so the token is the
// password and the username is whatever that client chose.
const [, password] = Buffer.from(basic[1], "base64").toString().split(":", 2);
return password || undefined;
}
Answer the call
import { Access, Denial } from "@/lib/gen/enroute/hook/v1alpha1/hook";
import type { AuthorizeRequest } from "@/lib/gen/enroute/hook/v1alpha1/hook";
import { repoByPath, userByToken } from "@/lib/store";
const CHALLENGE = {
wwwAuthenticate: 'Basic realm="codehost"',
// Git prints this as remote: lines. It needs the trailing newline.
help: "Use a token as the password. See http://127.0.0.1:3000/settings/tokens\n",
};
export async function authorize(req: AuthorizeRequest) {
// The .git suffix is a convention of the URL a person typed. The protocol
// gives it no meaning, so serving both spellings as one repository is ours
// to choose.
const path = req.repoPath.replace(/\.git$/, "");
const token = tokenFrom(req.headers);
const user = token ? await userByToken(token) : undefined;
if (!user) {
return { denied: { denial: Denial.DENIAL_UNAUTHORIZED, challenge: CHALLENGE } };
}
// Look the path up exactly as your app does to render a page.
const repo = await repoByPath(path);
if (!repo) {
return { denied: { denial: Denial.DENIAL_NOT_FOUND, challenge: undefined } };
}
if (req.access === Access.ACCESS_WRITE && repo.ownerId !== user.id) {
return { denied: { denial: Denial.DENIAL_FORBIDDEN, challenge: undefined } };
}
return {
granted: {
repo: { key: repo.id },
actor: user.id,
context: new TextEncoder().encode(JSON.stringify({ userId: user.id })),
},
};
}
Then dispatch to it, in app/api/enroute/hooks/route.ts:
async function dispatch(req: HookRequest): Promise<HookResponse> {
if (req.authorize) {
return HookResponse.fromPartial({ authorize: await authorize(req.authorize) });
}
return HookResponse.fromPartial({});
}
Authorization decisions
Answer with your key, never the path. repo.id is what you passed to
CreateRepository. A path is a name, and a name can be renamed; if the key
followed the name, a rename would silently point every push at a different
repository.
actor is an ID, never a credential and never an email address. Enroute
records it on the request's traces and in usage data, so it must be safe to
keep. It also comes from your lookup rather than from the request, because
the Git client is the one party that must not be trusted about who it is.
Enroute hands this same actor to every later hook of the request.
Choose between 404 and 401 deliberately. This example answers
DENIAL_NOT_FOUND for a repository the user cannot see, which is why the
permission check comes before the existence check. Answering NOT_FOUND only
for repositories that truly do not exist would tell an anonymous caller which
repositories exist. Which one to send is your decision; Enroute imposes no
order.
A 401 without a challenge is a dead end. Git's credential handling —
netrc, credential helpers, a prompted retry — engages only from the
WWW-Authenticate value. Without it the person behind the client is told no
and given no way to fix it.
The context field
Granted.context is up to 8 KiB of your own bytes. Enroute never parses or
logs it, and hands it back byte for byte on every later hook of the same Git
request.
It is captured now and replayed when the hooks run, which on a large push is
minutes later. Anything that can change in between should be checked again
rather than trusted from here. An empty context is fine, and common: later
chapters can look everything up from repo and actor.
Check your work
A clone with a token succeeds:
git clone http://ada:tok_ada@127.0.0.1:8080/acme/widgets.git
The repository is empty, so Git warns that you cloned nothing. That warning is
success: authorize granted, and Enroute served the repository your key named.
A clone with no credential asks for one, rather than failing outright:
git clone http://127.0.0.1:8080/acme/widgets.git
A clone of a path your app does not know is a 404:
git clone http://ada:tok_ada@127.0.0.1:8080/acme/nothing.git
A push by somebody who is not the owner is refused. Chapter 5 makes pushes work for the owner:
git clone http://linus:tok_linus@127.0.0.1:8080/acme/widgets.git
That last one succeeds, because reading is allowed for everyone here. Only writing checks the owner.
Result
- A route that turns a Git URL into one of your repository keys.
- Authentication from a token, with a challenge that lets Git ask for one.
- An actor that every later hook will carry.
Git can now read. It still cannot write, because a push asks two more questions.
Next: Process pushes.
Generated from docs/build/04-authenticate-git-clients.md at ead0474