Commit

Device sign-in replaces registering and minting tokens over the API

A tool asks for a code, the person approves it in their browser (signing in or registering there), and the tool collects a token. Accounts are now created only on the website; no endpoint takes a password. - identity: device_start, device_lookup, device_resolve, device_claim - api: /v1/device/code and /v1/device/token; /v1/register and /v1/tokens removed - site: /device approval page; sign-in and registration return to where the person was going - llms.txt and docs rewritten around the new flow

syntaqxcommitted Parentab28857Browse files
19 files+596−1290/19 viewed
+25−33
9999 await next();
100100 });
101101
102−// Onboarding. These two are REST only: they take a password, which has no
103−// place in an agent's tool call.
102+// Signing in from a tool. Accounts are created, and passwords typed, only
103+// in a browser; a tool gets its token by having a person approve a code.
104104
105105 async function jsonBody(request: Request): Promise<Record<string, unknown>> {
106106 try {
110110 }
111111 }
112112
113−app.post("/v1/register", async (c) => {
113+app.post("/v1/device/code", async (c) => {
114114 const body = await jsonBody(c.req.raw);
115− const result = await c.get("services").IDENTITY.register(
116− String(body.username ?? ""),
117− String(body.email ?? ""),
118− String(body.password ?? ""),
119− );
120− if (!result.ok) {
121− return c.json({ error: result.error }, httpStatus(result.error) as 409 | 422);
122− }
123− return c.json(
124− {
125− user: result.value.user,
126− next: "A confirmation link was sent to that email address. The account cannot create or push anything until the link is opened.",
127− },
128− 201,
129− );
115+ const started = await c
116+ .get("services")
117+ .IDENTITY.deviceStart(String(body.client_name ?? ""));
118+ return c.json({
119+ device_code: started.deviceCode,
120+ user_code: started.userCode,
121+ verification_uri: "https://g1t.sh/device",
122+ verification_uri_complete: `https://g1t.sh/device?code=${started.userCode}`,
123+ expires_in: started.expiresIn,
124+ interval: started.interval,
125+ });
130126 });
131127
132−app.post("/v1/tokens", async (c) => {
128+app.post("/v1/device/token", async (c) => {
133129 const body = await jsonBody(c.req.raw);
134− const identity = c.get("services").IDENTITY;
135− const password = String(body.password ?? "");
136− // An existing token must not be usable to mint more tokens.
137− const user = password.startsWith("g1t_")
138− ? null
139− : await identity.userForGitCredentials(String(body.username ?? ""), password);
140− if (!user) {
141− return c.json(
142− { error: { code: "unauthenticated", message: "Incorrect username or password." } },
143− 401,
144− );
145− }
146− const created = await identity.createAccessToken(user, String(body.name ?? ""));
147− return c.json({ token: created.token, verified: user.verified === true }, 201);
130+ const claim = await c
131+ .get("services")
132+ .IDENTITY.deviceClaim(String(body.device_code ?? ""));
133+ if (claim.status !== "approved") return c.json({ status: claim.status });
134+ return c.json({
135+ status: "approved",
136+ token: claim.token,
137+ username: claim.user.username,
138+ verified: claim.user.verified === true,
139+ });
148140 });
149141
150142 app.get("/openapi.json", (c) =>
+43−29
2929 return path.replace(/:([a-z_]+)/g, "{$1}");
3030 }
3131
32−/** Hand-written entries for the two onboarding routes, which are not operations. */
32+/** Hand-written entries for device sign-in, which is not an operation. */
3333 const ONBOARDING = {
34− "/v1/register": {
34+ "/v1/device/code": {
3535 post: {
36− operationId: "register",
36+ operationId: "device_code",
3737 tags: ["Accounts"],
38− summary: "Register",
39− description: "Create an account. Sends a confirmation email.",
38+ summary: "Start signing in",
39+ description:
40+ "Begins a device sign-in. Show the person `verification_uri_complete` and have them open it in a browser, where they sign in or register and approve the code. Then poll `/v1/device/token`.",
4041 security: [],
4142 requestBody: {
42− required: true,
4343 content: {
4444 "application/json": {
4545 schema: {
4646 type: "object",
47− required: ["username", "email", "password"],
4847 properties: {
49− username: {
48+ client_name: {
5049 type: "string",
51− description: "Lowercase letters, digits and single hyphens; at most 39 characters.",
50+ description: "What is asking, shown to the person approving. For example, Claude Code.",
5251 },
53− email: { type: "string", format: "email" },
54− password: { type: "string", minLength: 10 },
5552 },
5653 },
5754 },
5855 },
5956 },
6057 responses: {
61− "201": { description: "The account was created; a confirmation link was emailed." },
62− "409": errorResponse("The username or email is already registered."),
63− "422": errorResponse("The input is not valid."),
58+ "200": {
59+ description: "The codes for this sign-in.",
60+ content: {
61+ "application/json": {
62+ schema: {
63+ type: "object",
64+ properties: {
65+ device_code: { type: "string", description: "Secret. Send it to /v1/device/token." },
66+ user_code: { type: "string", description: "Shown to the person, like WDJB-MJHT." },
67+ verification_uri: { type: "string" },
68+ verification_uri_complete: {
69+ type: "string",
70+ description: "The link to give the person; it carries the code.",
71+ },
72+ expires_in: { type: "integer", description: "Seconds until the codes expire." },
73+ interval: { type: "integer", description: "Seconds to wait between polls." },
74+ },
75+ },
76+ },
77+ },
78+ },
6479 },
6580 },
6681 },
67− "/v1/tokens": {
82+ "/v1/device/token": {
6883 post: {
69− operationId: "create_token",
84+ operationId: "device_token",
7085 tags: ["Accounts"],
71− summary: "Create token",
72− description: "Create an access token from a username and password.",
86+ summary: "Finish signing in",
87+ description:
88+ "Asks whether the person has approved. Poll no faster than the interval. The token is returned once.",
7389 security: [],
7490 requestBody: {
7591 required: true,
7793 "application/json": {
7894 schema: {
7995 type: "object",
80− required: ["username", "password"],
81− properties: {
82− username: { type: "string" },
83− password: { type: "string" },
84− name: { type: "string", description: "A label for the token." },
85− },
96+ required: ["device_code"],
97+ properties: { device_code: { type: "string" } },
8698 },
8799 },
88100 },
89101 },
90102 responses: {
91− "201": {
92− description: "The token, shown once.",
103+ "200": {
104+ description: "The state of the sign-in.",
93105 content: {
94106 "application/json": {
95107 schema: {
96108 type: "object",
109+ required: ["status"],
97110 properties: {
98− token: { type: "string" },
111+ status: { type: "string", enum: ["pending", "approved", "denied", "expired"] },
112+ token: { type: "string", description: "Present when approved." },
113+ username: { type: "string" },
99114 verified: {
100115 type: "boolean",
101116 description: "Whether the account's email is confirmed.",
105120 },
106121 },
107122 },
108− "401": errorResponse("Incorrect username or password."),
109123 },
110124 },
111125 },
193207 servers: [{ url: "https://api.g1t.sh" }],
194208 security: [{ token: [] }, {}],
195209 tags: [
196− { name: "Accounts", description: "Registering and getting a token." },
210+ { name: "Accounts", description: "Signing in from a tool, and the current user." },
197211 { name: "Repositories" },
198212 { name: "Intents", description: "Goals stated against a repository." },
199213 { name: "Attempts", description: "An agent's or person's run at an intent." },
+36−17
55
66 ## Creating an account
77
8−Register at [g1t.sh/register](https://g1t.sh/register), or through the API:
8+Register at [g1t.sh/register](https://g1t.sh/register). Usernames are
9+lowercase letters, digits and single hyphens, up to 39 characters. Your
10+username is your namespace: `g1t.sh/<username>`.
911
10−```sh
11−curl -X POST https://api.g1t.sh/v1/register \
12− -H "Content-Type: application/json" \
13− -d '{"username": "you", "email": "you@example.com", "password": "at least ten characters"}'
14−```
15−
16−Usernames are lowercase letters, digits and single hyphens, up to 39
17−characters. Your username is your namespace: `g1t.sh/<username>`.
12+Accounts can only be created in a browser. There is no API for it, by
13+design: it keeps passwords out of scripts and agents, and lets g1t protect
14+the one place accounts are made.
1815
1916 ## Confirming your email
2017
3532 | API | `Authorization: Bearer g1t_…` |
3633 | MCP | The same header, set when you add the server. |
3734
38−Create one in [Settings](https://g1t.sh/settings), or with your password:
35+Create one in [Settings](https://g1t.sh/settings). A token is shown once,
36+when it is created; g1t stores only a hash of it. If you lose one, delete it
37+and create another. Delete a token the moment you think someone else has
38+seen it.
39+
40+A token has the full rights of your account. Scoped tokens are planned.
41+
42+## Signing in from a tool
43+
44+An agent or command-line tool gets a token without ever handling your
45+password, the same way `gh auth login` works:
3946
47+1. The tool asks g1t for a code and shows you a link and a short code such
48+ as `WDJB-MJHT`.
49+2. You open the link, sign in (or create an account), check that the code
50+ matches, and approve.
51+3. The tool collects its token.
52+
4053 ```sh
41−curl -X POST https://api.g1t.sh/v1/tokens \
42− -H "Content-Type: application/json" \
43− -d '{"username": "you", "password": "…", "name": "laptop"}'
54+# 1. The tool starts a sign-in.
55+curl -X POST https://api.g1t.sh/v1/device/code -H "Content-Type: application/json" -d '{"client_name": "my-tool"}'
56+
57+# 2. You open verification_uri_complete from the response and approve.
58+
59+# 3. The tool polls, no faster than "interval" seconds, until it is approved.
60+curl -X POST https://api.g1t.sh/v1/device/token -H "Content-Type: application/json" -d '{"device_code": "…"}'
4461 ```
4562
46−A token is shown once, when it is created. g1t stores only a hash of it. If
47−you lose one, delete it and create another. Delete a token the moment you
48−think someone else has seen it.
63+The poll answers with a `status` of `pending`, `approved`, `denied` or
64+`expired`. An approved answer carries the token, once. Codes expire after 15
65+minutes. The token appears in your settings under the tool's name, where you
66+can delete it.
4967
50−A token has the full rights of your account. Scoped tokens are planned.
68+Only approve a code you asked for. Approving gives the tool the full rights
69+of your account.
5170
5271 ## Resetting your password
5372
+2−2
1010 This page takes you from nothing to an agent working on your repository.
1111
1212 Prefer to have an assistant do it? Give it [g1t.sh/llms.txt](https://g1t.sh/llms.txt) and
13−ask it to set you up. It can do everything except open the confirmation
14−email.
13+ask it to set you up. It will give you a link to open in your browser, where
14+you create your account and approve it. It never sees your password.
1515
1616 ## 1. Create an account
1717
+7−5
2323 Public data can be read without a token. A token that is not valid is
2424 rejected with `401` rather than treated as anonymous.
2525
26−## Getting an account and a token
26+## Signing in from a tool
2727
28−These two calls need no token, so an assistant can set someone up from
29−scratch. See [llms.txt](https://g1t.sh/llms.txt) for the full walkthrough.
28+A tool gets a token by having a person approve a short code in their
29+browser. See [signing in from a tool](/guides/authentication/#signing-in-from-a-tool).
3030
3131 | Method | Path | |
3232 | --- | --- | --- |
33−| `POST` | `/v1/register` | Create an account. Body: `username`, `email`, `password`. Sends a confirmation email. |
34−| `POST` | `/v1/tokens` | Create an access token. Body: `username`, `password`, `name`. |
33+| `POST` | `/v1/device/code` | Start a sign-in. Body: `client_name`. |
34+| `POST` | `/v1/device/token` | Ask whether it was approved. Body: `device_code`. |
35+
36+Accounts are created in a browser only. There is no registration endpoint.
3537
3638 ## Errors
3739
+3−2
4545 export function requireUser(context: Context, request: Request): User {
4646 const viewer = getViewer(context);
4747 if (!viewer) {
48− const next = new URL(request.url).pathname;
49− throw redirect(`/login?next=${encodeURIComponent(next)}`);
48+ // Keep the query string: a device sign-in link carries its code there.
49+ const { pathname, search } = new URL(request.url);
50+ throw redirect(`/login?next=${encodeURIComponent(pathname + search)}`);
5051 }
5152 return viewer;
5253 }
+1−0
88 route("verify", "routes/verify.tsx"),
99 route("forgot", "routes/forgot.tsx"),
1010 route("reset", "routes/reset.tsx"),
11+ route("device", "routes/device.tsx"),
1112 route("new", "routes/new.tsx"),
1213 route("settings", "routes/settings.tsx"),
1314 route("explore", "routes/explore.tsx", { id: "explore" }),
+124−0
1+import { CircleCheck, CircleX, KeyRound } from "lucide-react";
2+import { Form } from "react-router";
3+
4+import type { Route } from "./+types/device";
5+import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { identity } from "../lib/services.server";
7+import { assertSameOrigin, requireUser } from "../lib/session.server";
8+
9+export function meta({}: Route.MetaArgs) {
10+ return [{ title: "Connect an application · g1t" }];
11+}
12+
13+/** Where a tool sends a person to approve its sign-in. */
14+export async function loader({ request, context }: Route.LoaderArgs) {
15+ const user = requireUser(context, request);
16+ const code = new URL(request.url).searchParams.get("code") ?? "";
17+ return {
18+ user,
19+ code,
20+ pending: code ? await identity.deviceLookup(code) : null,
21+ };
22+}
23+
24+export async function action({ request, context }: Route.ActionArgs) {
25+ assertSameOrigin(request);
26+ const user = requireUser(context, request);
27+ const form = await request.formData();
28+ const approve = form.get("decision") === "approve";
29+ const result = await identity.deviceResolve(
30+ String(form.get("code") ?? ""),
31+ user,
32+ approve,
33+ );
34+ return result.ok
35+ ? { done: approve ? ("approved" as const) : ("denied" as const) }
36+ : { error: result.error.message };
37+}
38+
39+export default function Device({ loaderData, actionData }: Route.ComponentProps) {
40+ const { user, code, pending } = loaderData;
41+
42+ if (actionData && "done" in actionData) {
43+ const approved = actionData.done === "approved";
44+ const Icon = approved ? CircleCheck : CircleX;
45+ return (
46+ <main className="mx-auto max-w-md px-4 py-32 text-center">
47+ <Icon size={40} className={`mx-auto ${approved ? "text-accent" : "text-muted"}`} />
48+ <h1 className="mt-6 text-2xl font-semibold tracking-tight">
49+ {approved ? "Connected" : "Request denied"}
50+ </h1>
51+ <p className="mt-2 text-muted">
52+ {approved
53+ ? "Go back to the application. It will finish signing in on its own."
54+ : "Nothing was given access to your account."}
55+ </p>
56+ </main>
57+ );
58+ }
59+
60+ return (
61+ <main className="mx-auto max-w-md px-4 py-24">
62+ <KeyRound size={36} className="text-accent" />
63+ <h1 className="mt-6 text-2xl font-semibold tracking-tight">
64+ Connect an application
65+ </h1>
66+
67+ {pending ? (
68+ <>
69+ <p className="mt-2 text-muted">
70+ <span className="font-medium text-fg">{pending.clientName}</span>{" "}
71+ wants to act as{" "}
72+ <span className="font-mono font-medium text-fg">{user.username}</span>{" "}
73+ on g1t.
74+ </p>
75+ <p className="mt-6 text-sm text-muted">
76+ Check that this code matches the one it is showing you:
77+ </p>
78+ <p className="mt-2 rounded-xl border border-line bg-surface py-5 text-center font-mono text-3xl font-semibold tracking-[0.2em]">
79+ {pending.userCode}
80+ </p>
81+ <p className="mt-4 text-sm text-muted">
82+ Approving creates an access token with the full rights of your
83+ account. You can delete it in settings at any time.
84+ </p>
85+ <Form method="post" className="mt-6 flex gap-3">
86+ <input type="hidden" name="code" value={pending.userCode} />
87+ <div className="grow *:w-full">
88+ <Button variant="accent" type="submit" name="decision" value="approve">
89+ Approve
90+ </Button>
91+ </div>
92+ <Button variant="quiet" type="submit" name="decision" value="deny">
93+ Deny
94+ </Button>
95+ </Form>
96+ </>
97+ ) : (
98+ <>
99+ <p className="mt-2 text-muted">
100+ {code
101+ ? "That code is not valid or has expired. Start again from the application, or enter the code it shows."
102+ : "Enter the code the application is showing you."}
103+ </p>
104+ <Form method="get" className="mt-6 space-y-4">
105+ <Field label="Code">
106+ <Input
107+ name="code"
108+ placeholder="XXXX-XXXX"
109+ autoComplete="off"
110+ autoCapitalize="characters"
111+ required
112+ autoFocus
113+ />
114+ </Field>
115+ <div className="*:w-full">
116+ <Button type="submit">Continue</Button>
117+ </div>
118+ </Form>
119+ </>
120+ )}
121+ <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
122+ </main>
123+ );
124+}
+7−3
1717
1818 export function loader({ request, context }: Route.LoaderArgs) {
1919 if (getViewer(context)) throw redirect(nextPath(request));
20− return null;
20+ return { next: nextPath(request) };
2121 }
2222
2323 export async function action({ request }: Route.ActionArgs) {
3333 });
3434 }
3535
36−export default function Login({ actionData }: Route.ComponentProps) {
36+export default function Login({ loaderData, actionData }: Route.ComponentProps) {
37+ const registerUrl =
38+ loaderData.next === "/"
39+ ? "/register"
40+ : `/register?next=${encodeURIComponent(loaderData.next)}`;
3741 return (
3842 <AuthCard
3943 title="Welcome back"
4145 footer={
4246 <>
4347 New to g1t?{" "}
44− <Link to="/register" className="text-fg underline underline-offset-4">
48+ <Link to={registerUrl} className="text-fg underline underline-offset-4">
4549 Create an account
4650 </Link>
4751 </>
+4−3
77 import {
88 assertSameOrigin,
99 getViewer,
10+ nextPath,
1011 startSession,
1112 } from "../lib/session.server";
1213
1415 return [{ title: "Create an account · g1t" }];
1516 }
1617
17−export function loader({ context }: Route.LoaderArgs) {
18− if (getViewer(context)) throw redirect("/");
18+export function loader({ request, context }: Route.LoaderArgs) {
19+ if (getViewer(context)) throw redirect(nextPath(request));
1920 return null;
2021 }
2122
2829 String(form.get("password") ?? ""),
2930 );
3031 if (!result.ok) return { error: result.error.message };
31− throw redirect("/", {
32+ throw redirect(nextPath(request), {
3233 headers: { "set-cookie": startSession(result.value.sessionToken) },
3334 });
3435 }
+41−33
77 > requests: an attempt is a pull request, an intent is the goal it serves.
88
99 This file tells an assistant everything needed to get a person set up on g1t
10−and working. Follow the steps in order. Only step 2 needs the person.
10+and working. You never ask for, see, or send the person's password. Accounts
11+are created and approved only in their browser.
1112
1213 ## Set someone up
1314
14−1. **Register.** Ask the person for a username (lowercase letters, digits and
15− single hyphens, at most 39 characters), their email, and a password of at
16− least 10 characters. Then:
15+1. **Start a sign-in.**
1716
1817 ```sh
19− curl -X POST https://api.g1t.sh/v1/register \
18+ curl -X POST https://api.g1t.sh/v1/device/code \
2019 -H "Content-Type: application/json" \
21− -d '{"username": "USERNAME", "email": "EMAIL", "password": "PASSWORD"}'
20+ -d '{"client_name": "Claude Code"}'
2221 ```
2322
24− `201` means the account exists. `409` means the username or email is
25− taken. `422` explains what is wrong with the input. They can instead
26− register in a browser at https://g1t.sh/register.
23+ The response has `device_code` (keep it; do not show it),
24+ `user_code` (like `WDJB-MJHT`), `verification_uri_complete`, `interval`
25+ and `expires_in`.
2726
28−2. **Confirm the email.** g1t sends a message from `noreply@g1t.sh` with a
29− confirmation link that works for 24 hours. Ask the person to open their
30− inbox and follow it, and wait until they say they have. Until then the
31− account can sign in but cannot create repositories, push, or open intents;
32− those calls return `403` with a message saying to confirm the address. If
33− the message is missing, they can sign in at https://g1t.sh and use the
34− "Send it again" banner.
27+2. **Send the person to their browser.** Give them the
28+ `verification_uri_complete` link and tell them the `user_code` they
29+ should see there. On that page they sign in, or choose "Create an
30+ account" if they are new, and then approve the request. Wait for them.
31+
32+ A new account also gets a confirmation email from `noreply@g1t.sh`. Ask
33+ them to open it and follow the link. Until they do, the account cannot
34+ create repositories, push, or open intents: those calls return `403`
35+ with a message saying to confirm the address.
3536
36−3. **Create an access token.**
37+3. **Collect the token.** Poll every `interval` seconds, not faster:
3738
3839 ```sh
39− curl -X POST https://api.g1t.sh/v1/tokens \
40+ curl -X POST https://api.g1t.sh/v1/device/token \
4041 -H "Content-Type: application/json" \
41− -d '{"username": "USERNAME", "password": "PASSWORD", "name": "my laptop"}'
42+ -d '{"device_code": "DEVICE_CODE"}'
4243 ```
4344
44− The response is `{"token": "g1t_…", "verified": true}`. The token is shown
45− once. It is the password for git and the bearer token for the API and the
46− MCP server. Store it as `G1T_TOKEN`; do not write it into a repository.
47− If `verified` is `false`, step 2 is not finished.
45+ `{"status": "pending"}` means keep waiting. `denied` and `expired` mean
46+ start again from step 1. `approved` comes with `token`, `username` and
47+ `verified`. The token is returned once. It is the password for git and
48+ the bearer token for the API and the MCP server. Store it as `G1T_TOKEN`;
49+ never write it into a repository. If `verified` is `false`, the
50+ confirmation email has not been followed yet.
4851
4952 4. **Connect the MCP server** (Claude Code shown; any MCP client with HTTP
5053 transport works):
8083 `message`, `tool_call`, `tool_result`, `note`. Never include secrets;
8184 sessions are as visible as the repository.
8285 - **Submit:** `POST /v1/attempts/{attempt_id}/submit` with `summary`.
86+- **See what an attempt changed:** `GET /v1/attempts/{attempt_id}/changes`.
8387 - **Ship** (repository owner only):
8488 `POST /v1/attempts/{attempt_id}/ship`. A `409` saying main has moved means
8589 the fork is behind: pull main from `https://g1t.sh/{owner}/{name}.git` into
8791
8892 Every one of these is also an MCP tool with the same name in snake case:
8993 `open_intent`, `start_attempt`, `record_session`, `submit_attempt`,
90−`ship_attempt`, and `list_intents`, `get_intent`, `get_attempt`,
91−`read_session`, `list_repos`, `get_repo`, `create_repo`, `list_events`,
92−`whoami`.
94+`get_attempt_changes`, `ship_attempt`, and `list_intents`, `get_intent`,
95+`get_attempt`, `read_session`, `list_repos`, `get_repo`, `create_repo`,
96+`list_events`, `whoami`.
9397
9498 ## Facts
9599
97101 Public data needs no token. Errors are
98102 `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated`
99103 (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid`
100− (422).
104+ (422). The full description is at https://api.g1t.sh/openapi.json.
101105 - Git remote: `https://g1t.sh/{owner}/{repo}.git`. Attempt forks:
102106 `https://g1t.sh/attempts/{attempt_id}.git`. SSH is not available.
103107 - Limits: 1 GB per repository, 32 MB per file, 100 MB per push.
104−- Forgotten password: https://g1t.sh/forgot.
105−- Not available yet: diffs and review on the site, merging on the server,
106− running checks automatically, agents hosted by g1t, OAuth sign-in for MCP.
108+- Forgotten password: https://g1t.sh/forgot (the person does this, in a
109+ browser).
110+- Not available yet: merging on the server, running checks automatically,
111+ OAuth sign-in for MCP.
107112
108113 ## More
109114
110−- [Getting started](https://docs.g1t.sh/quickstart/)
115+- [Quickstart](https://docs.g1t.sh/quickstart/)
111116 - [Concepts](https://docs.g1t.sh/concepts/overview/)
117+- [Forks and branches](https://docs.g1t.sh/concepts/forks/)
118+- [Accounts and authentication](https://docs.g1t.sh/guides/authentication/)
112119 - [Git](https://docs.g1t.sh/guides/git/)
113−- [Connect an agent](https://docs.g1t.sh/guides/bring-your-own-agent/)
114−- [API reference](https://docs.g1t.sh/reference/api/)
120+- [g1t agents](https://docs.g1t.sh/guides/g1t-agents/)
121+- [Bring your own agent](https://docs.g1t.sh/guides/bring-your-own-agent/)
122+- [API reference](https://docs.g1t.sh/api/reference/)
115123 - [Source](https://g1t.sh/syntaqx/g1t), MIT licensed
+69−0
143143 pub token: String,
144144 pub password: String,
145145 }
146+
147+/// `device_start`: begins a device sign-in. Returns `DeviceStart`.
148+#[derive(Debug, Serialize, Deserialize)]
149+#[serde(rename_all = "camelCase")]
150+pub struct DeviceStartArgs {
151+ /// What is asking, shown to the person approving, e.g. "Claude Code".
152+ pub client_name: String,
153+}
154+
155+#[derive(Debug, Serialize, Deserialize)]
156+#[serde(rename_all = "camelCase")]
157+pub struct DeviceStart {
158+ /// Secret held by the tool and exchanged for a token once approved.
159+ pub device_code: String,
160+ /// Short code shown to the person, e.g. `WDJB-MJHT`.
161+ pub user_code: String,
162+ /// Seconds until both codes stop working.
163+ pub expires_in: u32,
164+ /// Seconds the tool should wait between polls.
165+ pub interval: u32,
166+}
167+
168+/// `device_lookup`: what a user code is asking for, or null if it is not
169+/// valid. Returns `Option<DeviceRequest>`.
170+#[derive(Debug, Serialize, Deserialize)]
171+#[serde(rename_all = "camelCase")]
172+pub struct DeviceLookupArgs {
173+ pub user_code: String,
174+}
175+
176+#[derive(Debug, Serialize, Deserialize)]
177+#[serde(rename_all = "camelCase")]
178+pub struct DeviceRequest {
179+ pub user_code: String,
180+ pub client_name: String,
181+}
182+
183+/// `device_resolve`: the signed-in person approves or denies a request.
184+/// Returns `Outcome<bool>`.
185+#[derive(Debug, Serialize, Deserialize)]
186+#[serde(rename_all = "camelCase")]
187+pub struct DeviceResolveArgs {
188+ pub user_code: String,
189+ pub user: User,
190+ pub approve: bool,
191+}
192+
193+/// `device_claim`: the tool asks whether its request was approved.
194+#[derive(Debug, Serialize, Deserialize)]
195+#[serde(rename_all = "camelCase")]
196+pub struct DeviceClaimArgs {
197+ pub device_code: String,
198+}
199+
200+/// The answer to a `device_claim`.
201+#[derive(Debug, Serialize, Deserialize)]
202+#[serde(tag = "status", rename_all = "snake_case")]
203+pub enum DeviceClaim {
204+ /// Nobody has approved or denied it yet; ask again after the interval.
205+ Pending,
206+ Denied,
207+ /// The code was never issued, has expired, or was already used.
208+ Expired,
209+ /// The access token, returned once.
210+ Approved {
211+ token: String,
212+ user: User,
213+ },
214+}
+1−1
22 const RESERVED: &[&str] = &[
33 "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth",
44 "attempts", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", "terms",
5− "privacy", "help", "support", "status", "blog", "verify", "forgot", "reset",
5+ "privacy", "help", "support", "status", "blog", "verify", "forgot", "reset", "device",
66 ];
77
88 /// Namespaces follow GitHub's rules: letters, digits and single hyphens,
+5−0
3939 requestPasswordReset: (email) => call("request_password_reset", { email }),
4040 resetPassword: (token, password) =>
4141 call("reset_password", { token, password }),
42+ deviceStart: (clientName) => call("device_start", { clientName }),
43+ deviceLookup: (userCode) => call("device_lookup", { userCode }),
44+ deviceResolve: (userCode, user, approve) =>
45+ call("device_resolve", { userCode, user, approve }),
46+ deviceClaim: (deviceCode) => call("device_claim", { deviceCode }),
4247 userForSession: (sessionToken) => call("user_for_session", { sessionToken }),
4348 userForGitCredentials: (username, secret) =>
4449 call("user_for_git_credentials", { username, secret }),
+27−0
2222
2323 export type AccessToken = { id: string; name: string; createdAt: number };
2424
25+export type DeviceStart = {
26+ /** Secret held by the tool and exchanged for a token once approved. */
27+ deviceCode: string;
28+ /** Short code shown to the person, e.g. `WDJB-MJHT`. */
29+ userCode: string;
30+ /** Seconds until both codes stop working. */
31+ expiresIn: number;
32+ /** Seconds the tool should wait between polls. */
33+ interval: number;
34+};
35+
36+export type DeviceRequest = { userCode: string; clientName: string };
37+
38+export type DeviceClaim =
39+ | { status: "pending" | "denied" | "expired" }
40+ | { status: "approved"; token: string; user: User };
41+
2542 /** Accounts, credentials and sessions. */
2643 export interface IdentityApi {
2744 /** Creates an account and signs it in. */
3956 /** Sets a new password from an emailed token and ends every session. */
4057 resetPassword(token: string, password: string): Promise<Result<User>>;
4158
59+ /**
60+ * Device sign-in (RFC 8628). A tool starts a request, a person approves
61+ * its short code in a browser, and the tool claims an access token.
62+ */
63+ deviceStart(clientName: string): Promise<DeviceStart>;
64+ /** What a user code is asking for, or null if it is not valid. */
65+ deviceLookup(userCode: string): Promise<DeviceRequest | null>;
66+ deviceResolve(userCode: string, user: User, approve: boolean): Promise<Result<boolean>>;
67+ deviceClaim(deviceCode: string): Promise<DeviceClaim>;
68+
4269 userForSession(sessionToken: string): Promise<Viewer>;
4370
4471 /** Verifies git credentials: the account password or an access token. */
+1−1
66 /** Routes and reserved words that may not be registered as usernames. */
77 const RESERVED = new Set([
88 "api", "mcp", "login", "logout", "register", "new", "settings", "search",
9− "admin", "auth", "attempts", "verify", "forgot", "reset", "oauth", "assets", "docs", "explore", "g1t", "about",
9+ "admin", "auth", "attempts", "verify", "forgot", "reset", "device", "oauth", "assets", "docs", "explore", "g1t", "about",
1010 ]);
1111
1212 export function isValidNamespace(value: string): boolean {
+16−0
1+-- Sign-in for agents and command-line tools (RFC 8628). The tool shows a
2+-- person a short code; the person approves it in a browser; the tool then
3+-- collects an access token.
4+--
5+-- Timestamps are RFC 3339 UTC text, which sorts and compares as text.
6+CREATE TABLE device_codes (
7+ -- SHA-256 of the device code the tool holds.
8+ id TEXT PRIMARY KEY,
9+ -- What the person types or sees, e.g. WDJB-MJHT.
10+ user_code TEXT NOT NULL UNIQUE,
11+ client_name TEXT NOT NULL,
12+ -- 'pending', 'approved' or 'denied'
13+ status TEXT NOT NULL DEFAULT 'pending',
14+ user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
15+ expires_at TEXT NOT NULL
16+);
+179−0
1+//! Device sign-in (RFC 8628): how an agent or command-line tool gets an
2+//! access token without ever seeing a password.
3+//!
4+//! The tool starts a request and shows the person a short code and a URL.
5+//! The person signs in on the website, or registers there, and approves the
6+//! code. The tool polls until that happens and receives a token. Account
7+//! creation and passwords stay in the browser, where they can be protected.
8+
9+use g1t_contracts::identity::*;
10+use g1t_contracts::{FailureCode, Outcome, User};
11+use serde::Deserialize;
12+use worker::Result;
13+
14+use crate::{Identity, crypto};
15+
16+const EXPIRES_IN_SECONDS: u32 = 15 * 60;
17+const POLL_INTERVAL_SECONDS: u32 = 5;
18+/// No vowels, so a code never spells a word, and nothing easily confused.
19+const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
20+/// SQLite's expression for the current time as RFC 3339 UTC text.
21+const NOW: &str = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')";
22+
23+#[derive(Deserialize)]
24+struct DeviceRow {
25+ user_code: String,
26+ client_name: String,
27+ status: String,
28+ user_id: Option<String>,
29+}
30+
31+/// Eight letters as `XXXX-XXXX`.
32+fn new_user_code() -> String {
33+ let mut random = [0u8; 8];
34+ getrandom::getrandom(&mut random).expect("no source of randomness");
35+ let letters: String = random
36+ .iter()
37+ .map(|byte| USER_CODE_ALPHABET[*byte as usize % USER_CODE_ALPHABET.len()] as char)
38+ .collect();
39+ format!("{}-{}", &letters[..4], &letters[4..])
40+}
41+
42+/// A user code as stored, however it was typed.
43+fn normalize(user_code: &str) -> String {
44+ let letters: String = user_code
45+ .chars()
46+ .filter(char::is_ascii_alphabetic)
47+ .map(|c| c.to_ascii_uppercase())
48+ .collect();
49+ match letters.len() {
50+ 8 => format!("{}-{}", &letters[..4], &letters[4..]),
51+ _ => letters,
52+ }
53+}
54+
55+impl Identity {
56+ pub async fn device_start(&self, a: DeviceStartArgs) -> Result<DeviceStart> {
57+ let device_code = crypto::random_hex(32);
58+ let user_code = new_user_code();
59+ let client_name: String = match a.client_name.trim() {
60+ "" => "An application".to_owned(),
61+ name => name.chars().take(60).collect(),
62+ };
63+ self.db
64+ .prepare(format!(
65+ "INSERT INTO device_codes (id, user_code, client_name, expires_at)
66+ VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+{EXPIRES_IN_SECONDS} seconds'))"
67+ ))
68+ .bind(&[
69+ crypto::sha256_hex(&device_code).into(),
70+ user_code.as_str().into(),
71+ client_name.into(),
72+ ])?
73+ .run()
74+ .await?;
75+ Ok(DeviceStart {
76+ device_code,
77+ user_code,
78+ expires_in: EXPIRES_IN_SECONDS,
79+ interval: POLL_INTERVAL_SECONDS,
80+ })
81+ }
82+
83+ /// The pending, unexpired request with this user code.
84+ async fn pending_device(&self, user_code: &str) -> Result<Option<DeviceRow>> {
85+ self.db
86+ .prepare(format!(
87+ "SELECT user_code, client_name, status, user_id FROM device_codes
88+ WHERE user_code = ? AND status = 'pending' AND expires_at > {NOW}"
89+ ))
90+ .bind(&[normalize(user_code).into()])?
91+ .first::<DeviceRow>(None)
92+ .await
93+ }
94+
95+ pub async fn device_lookup(&self, a: DeviceLookupArgs) -> Result<Option<DeviceRequest>> {
96+ Ok(self
97+ .pending_device(&a.user_code)
98+ .await?
99+ .map(|row| DeviceRequest {
100+ user_code: row.user_code,
101+ client_name: row.client_name,
102+ }))
103+ }
104+
105+ pub async fn device_resolve(&self, a: DeviceResolveArgs) -> Result<Outcome<bool>> {
106+ let Some(row) = self.pending_device(&a.user_code).await? else {
107+ return Ok(Outcome::fail(
108+ FailureCode::NotFound,
109+ "That code is not valid or has expired. Start again from the application.",
110+ ));
111+ };
112+ self.db
113+ .prepare("UPDATE device_codes SET status = ?, user_id = ? WHERE user_code = ?")
114+ .bind(&[
115+ if a.approve { "approved" } else { "denied" }.into(),
116+ a.user.id.into(),
117+ row.user_code.into(),
118+ ])?
119+ .run()
120+ .await?;
121+ Ok(Outcome::Ok(a.approve))
122+ }
123+
124+ pub async fn device_claim(&self, a: DeviceClaimArgs) -> Result<DeviceClaim> {
125+ let id = crypto::sha256_hex(&a.device_code);
126+ let row = self
127+ .db
128+ .prepare(format!(
129+ "SELECT user_code, client_name, status, user_id FROM device_codes
130+ WHERE id = ? AND expires_at > {NOW}"
131+ ))
132+ .bind(&[id.as_str().into()])?
133+ .first::<DeviceRow>(None)
134+ .await?;
135+ let Some(row) = row else {
136+ return Ok(DeviceClaim::Expired);
137+ };
138+ let user_id = match (row.status.as_str(), row.user_id) {
139+ ("pending", _) => return Ok(DeviceClaim::Pending),
140+ ("approved", Some(user_id)) => user_id,
141+ _ => {
142+ self.forget_device(&id).await?;
143+ return Ok(DeviceClaim::Denied);
144+ }
145+ };
146+ // A device code yields exactly one token.
147+ self.forget_device(&id).await?;
148+ let Some(user) = self
149+ .find_user(
150+ "SELECT id, username, email_verified_at IS NOT NULL AS verified
151+ FROM users WHERE id = ?",
152+ &user_id,
153+ )
154+ .await?
155+ else {
156+ return Ok(DeviceClaim::Expired);
157+ };
158+ let created = self
159+ .create_access_token(CreateAccessTokenArgs {
160+ user: User { ..user.clone() },
161+ name: row.client_name,
162+ ttl_seconds: None,
163+ })
164+ .await?;
165+ Ok(DeviceClaim::Approved {
166+ token: created.token,
167+ user,
168+ })
169+ }
170+
171+ async fn forget_device(&self, id: &str) -> Result<()> {
172+ self.db
173+ .prepare("DELETE FROM device_codes WHERE id = ?")
174+ .bind(&[id.into()])?
175+ .run()
176+ .await?;
177+ Ok(())
178+ }
179+}
+5−0
44 //! the methods and their arguments.
55
66 mod crypto;
7+mod device;
78 mod email;
89
910 use g1t_contracts::identity::*;
549550 match method.as_str() {
550551 "register" => reply(&identity.register(args(body)?).await?),
551552 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
553+ "device_start" => reply(&identity.device_start(args(body)?).await?),
554+ "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
555+ "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
556+ "device_claim" => reply(&identity.device_claim(args(body)?).await?),
552557 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
553558 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
554559 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),