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
19 files+596−1290/19 viewed
| 99 | 99 | await next(); | |
| 100 | 100 | }); | |
| 101 | 101 | ||
| 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. | |
| 104 | 104 | ||
| 105 | 105 | async function jsonBody(request: Request): Promise<Record<string, unknown>> { | |
| 106 | 106 | try { | |
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | − | app.post("/v1/register", async (c) => { | |
| 113 | + | app.post("/v1/device/code", async (c) => { | |
| 114 | 114 | 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 | + | }); | |
| 130 | 126 | }); | |
| 131 | 127 | ||
| 132 | − | app.post("/v1/tokens", async (c) => { | |
| 128 | + | app.post("/v1/device/token", async (c) => { | |
| 133 | 129 | 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 | + | }); | |
| 148 | 140 | }); | |
| 149 | 141 | ||
| 150 | 142 | app.get("/openapi.json", (c) => |
| 29 | 29 | return path.replace(/:([a-z_]+)/g, "{$1}"); | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 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. */ | |
| 33 | 33 | const ONBOARDING = { | |
| 34 | − | "/v1/register": { | |
| 34 | + | "/v1/device/code": { | |
| 35 | 35 | post: { | |
| 36 | − | operationId: "register", | |
| 36 | + | operationId: "device_code", | |
| 37 | 37 | 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`.", | |
| 40 | 41 | security: [], | |
| 41 | 42 | requestBody: { | |
| 42 | − | required: true, | |
| 43 | 43 | content: { | |
| 44 | 44 | "application/json": { | |
| 45 | 45 | schema: { | |
| 46 | 46 | type: "object", | |
| 47 | − | required: ["username", "email", "password"], | |
| 48 | 47 | properties: { | |
| 49 | − | username: { | |
| 48 | + | client_name: { | |
| 50 | 49 | 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.", | |
| 52 | 51 | }, | |
| 53 | − | email: { type: "string", format: "email" }, | |
| 54 | − | password: { type: "string", minLength: 10 }, | |
| 55 | 52 | }, | |
| 56 | 53 | }, | |
| 57 | 54 | }, | |
| 58 | 55 | }, | |
| 59 | 56 | }, | |
| 60 | 57 | 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 | + | }, | |
| 64 | 79 | }, | |
| 65 | 80 | }, | |
| 66 | 81 | }, | |
| 67 | − | "/v1/tokens": { | |
| 82 | + | "/v1/device/token": { | |
| 68 | 83 | post: { | |
| 69 | − | operationId: "create_token", | |
| 84 | + | operationId: "device_token", | |
| 70 | 85 | 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.", | |
| 73 | 89 | security: [], | |
| 74 | 90 | requestBody: { | |
| 75 | 91 | required: true, | |
| 77 | 93 | "application/json": { | |
| 78 | 94 | schema: { | |
| 79 | 95 | 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" } }, | |
| 86 | 98 | }, | |
| 87 | 99 | }, | |
| 88 | 100 | }, | |
| 89 | 101 | }, | |
| 90 | 102 | responses: { | |
| 91 | − | "201": { | |
| 92 | − | description: "The token, shown once.", | |
| 103 | + | "200": { | |
| 104 | + | description: "The state of the sign-in.", | |
| 93 | 105 | content: { | |
| 94 | 106 | "application/json": { | |
| 95 | 107 | schema: { | |
| 96 | 108 | type: "object", | |
| 109 | + | required: ["status"], | |
| 97 | 110 | 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" }, | |
| 99 | 114 | verified: { | |
| 100 | 115 | type: "boolean", | |
| 101 | 116 | description: "Whether the account's email is confirmed.", | |
| 105 | 120 | }, | |
| 106 | 121 | }, | |
| 107 | 122 | }, | |
| 108 | − | "401": errorResponse("Incorrect username or password."), | |
| 109 | 123 | }, | |
| 110 | 124 | }, | |
| 111 | 125 | }, | |
| 193 | 207 | servers: [{ url: "https://api.g1t.sh" }], | |
| 194 | 208 | security: [{ token: [] }, {}], | |
| 195 | 209 | tags: [ | |
| 196 | − | { name: "Accounts", description: "Registering and getting a token." }, | |
| 210 | + | { name: "Accounts", description: "Signing in from a tool, and the current user." }, | |
| 197 | 211 | { name: "Repositories" }, | |
| 198 | 212 | { name: "Intents", description: "Goals stated against a repository." }, | |
| 199 | 213 | { name: "Attempts", description: "An agent's or person's run at an intent." }, |
| 5 | 5 | ||
| 6 | 6 | ## Creating an account | |
| 7 | 7 | ||
| 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>`. | |
| 9 | 11 | ||
| 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. | |
| 18 | 15 | ||
| 19 | 16 | ## Confirming your email | |
| 20 | 17 | ||
| 35 | 32 | | API | `Authorization: Bearer g1t_…` | | |
| 36 | 33 | | MCP | The same header, set when you add the server. | | |
| 37 | 34 | ||
| 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: | |
| 39 | 46 | ||
| 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 | + | ||
| 40 | 53 | ```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": "…"}' | |
| 44 | 61 | ``` | |
| 45 | 62 | ||
| 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. | |
| 49 | 67 | ||
| 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. | |
| 51 | 70 | ||
| 52 | 71 | ## Resetting your password | |
| 53 | 72 |
| 10 | 10 | This page takes you from nothing to an agent working on your repository. | |
| 11 | 11 | ||
| 12 | 12 | 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. | |
| 15 | 15 | ||
| 16 | 16 | ## 1. Create an account | |
| 17 | 17 |
| 23 | 23 | Public data can be read without a token. A token that is not valid is | |
| 24 | 24 | rejected with `401` rather than treated as anonymous. | |
| 25 | 25 | ||
| 26 | − | ## Getting an account and a token | |
| 26 | + | ## Signing in from a tool | |
| 27 | 27 | ||
| 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). | |
| 30 | 30 | ||
| 31 | 31 | | Method | Path | | | |
| 32 | 32 | | --- | --- | --- | | |
| 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. | |
| 35 | 37 | ||
| 36 | 38 | ## Errors | |
| 37 | 39 |
| 45 | 45 | export function requireUser(context: Context, request: Request): User { | |
| 46 | 46 | const viewer = getViewer(context); | |
| 47 | 47 | 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)}`); | |
| 50 | 51 | } | |
| 51 | 52 | return viewer; | |
| 52 | 53 | } |
| 8 | 8 | route("verify", "routes/verify.tsx"), | |
| 9 | 9 | route("forgot", "routes/forgot.tsx"), | |
| 10 | 10 | route("reset", "routes/reset.tsx"), | |
| 11 | + | route("device", "routes/device.tsx"), | |
| 11 | 12 | route("new", "routes/new.tsx"), | |
| 12 | 13 | route("settings", "routes/settings.tsx"), | |
| 13 | 14 | route("explore", "routes/explore.tsx", { id: "explore" }), |
| 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 | + | } |
| 17 | 17 | ||
| 18 | 18 | export function loader({ request, context }: Route.LoaderArgs) { | |
| 19 | 19 | if (getViewer(context)) throw redirect(nextPath(request)); | |
| 20 | − | return null; | |
| 20 | + | return { next: nextPath(request) }; | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | export async function action({ request }: Route.ActionArgs) { | |
| 33 | 33 | }); | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 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)}`; | |
| 37 | 41 | return ( | |
| 38 | 42 | <AuthCard | |
| 39 | 43 | title="Welcome back" | |
| 41 | 45 | footer={ | |
| 42 | 46 | <> | |
| 43 | 47 | 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"> | |
| 45 | 49 | Create an account | |
| 46 | 50 | </Link> | |
| 47 | 51 | </> |
| 7 | 7 | import { | |
| 8 | 8 | assertSameOrigin, | |
| 9 | 9 | getViewer, | |
| 10 | + | nextPath, | |
| 10 | 11 | startSession, | |
| 11 | 12 | } from "../lib/session.server"; | |
| 12 | 13 | ||
| 14 | 15 | return [{ title: "Create an account · g1t" }]; | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 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)); | |
| 19 | 20 | return null; | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 28 | 29 | String(form.get("password") ?? ""), | |
| 29 | 30 | ); | |
| 30 | 31 | if (!result.ok) return { error: result.error.message }; | |
| 31 | − | throw redirect("/", { | |
| 32 | + | throw redirect(nextPath(request), { | |
| 32 | 33 | headers: { "set-cookie": startSession(result.value.sessionToken) }, | |
| 33 | 34 | }); | |
| 34 | 35 | } |
| 7 | 7 | > requests: an attempt is a pull request, an intent is the goal it serves. | |
| 8 | 8 | ||
| 9 | 9 | 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. | |
| 11 | 12 | ||
| 12 | 13 | ## Set someone up | |
| 13 | 14 | ||
| 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.** | |
| 17 | 16 | ||
| 18 | 17 | ```sh | |
| 19 | − | curl -X POST https://api.g1t.sh/v1/register \ | |
| 18 | + | curl -X POST https://api.g1t.sh/v1/device/code \ | |
| 20 | 19 | -H "Content-Type: application/json" \ | |
| 21 | − | -d '{"username": "USERNAME", "email": "EMAIL", "password": "PASSWORD"}' | |
| 20 | + | -d '{"client_name": "Claude Code"}' | |
| 22 | 21 | ``` | |
| 23 | 22 | ||
| 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`. | |
| 27 | 26 | ||
| 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. | |
| 35 | 36 | ||
| 36 | − | 3. **Create an access token.** | |
| 37 | + | 3. **Collect the token.** Poll every `interval` seconds, not faster: | |
| 37 | 38 | ||
| 38 | 39 | ```sh | |
| 39 | − | curl -X POST https://api.g1t.sh/v1/tokens \ | |
| 40 | + | curl -X POST https://api.g1t.sh/v1/device/token \ | |
| 40 | 41 | -H "Content-Type: application/json" \ | |
| 41 | − | -d '{"username": "USERNAME", "password": "PASSWORD", "name": "my laptop"}' | |
| 42 | + | -d '{"device_code": "DEVICE_CODE"}' | |
| 42 | 43 | ``` | |
| 43 | 44 | ||
| 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. | |
| 48 | 51 | ||
| 49 | 52 | 4. **Connect the MCP server** (Claude Code shown; any MCP client with HTTP | |
| 50 | 53 | transport works): | |
| 80 | 83 | `message`, `tool_call`, `tool_result`, `note`. Never include secrets; | |
| 81 | 84 | sessions are as visible as the repository. | |
| 82 | 85 | - **Submit:** `POST /v1/attempts/{attempt_id}/submit` with `summary`. | |
| 86 | + | - **See what an attempt changed:** `GET /v1/attempts/{attempt_id}/changes`. | |
| 83 | 87 | - **Ship** (repository owner only): | |
| 84 | 88 | `POST /v1/attempts/{attempt_id}/ship`. A `409` saying main has moved means | |
| 85 | 89 | the fork is behind: pull main from `https://g1t.sh/{owner}/{name}.git` into | |
| 87 | 91 | ||
| 88 | 92 | Every one of these is also an MCP tool with the same name in snake case: | |
| 89 | 93 | `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`. | |
| 93 | 97 | ||
| 94 | 98 | ## Facts | |
| 95 | 99 | ||
| 97 | 101 | Public data needs no token. Errors are | |
| 98 | 102 | `{"error": {"code": "…", "message": "…"}}` with codes `unauthenticated` | |
| 99 | 103 | (401), `forbidden` (403), `not_found` (404), `conflict` (409), `invalid` | |
| 100 | − | (422). | |
| 104 | + | (422). The full description is at https://api.g1t.sh/openapi.json. | |
| 101 | 105 | - Git remote: `https://g1t.sh/{owner}/{repo}.git`. Attempt forks: | |
| 102 | 106 | `https://g1t.sh/attempts/{attempt_id}.git`. SSH is not available. | |
| 103 | 107 | - 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. | |
| 107 | 112 | ||
| 108 | 113 | ## More | |
| 109 | 114 | ||
| 110 | − | - [Getting started](https://docs.g1t.sh/quickstart/) | |
| 115 | + | - [Quickstart](https://docs.g1t.sh/quickstart/) | |
| 111 | 116 | - [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/) | |
| 112 | 119 | - [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/) | |
| 115 | 123 | - [Source](https://g1t.sh/syntaqx/g1t), MIT licensed |
| 143 | 143 | pub token: String, | |
| 144 | 144 | pub password: String, | |
| 145 | 145 | } | |
| 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 | + | } |
| 2 | 2 | const RESERVED: &[&str] = &[ | |
| 3 | 3 | "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth", | |
| 4 | 4 | "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", | |
| 6 | 6 | ]; | |
| 7 | 7 | ||
| 8 | 8 | /// Namespaces follow GitHub's rules: letters, digits and single hyphens, |
| 39 | 39 | requestPasswordReset: (email) => call("request_password_reset", { email }), | |
| 40 | 40 | resetPassword: (token, password) => | |
| 41 | 41 | 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 }), | |
| 42 | 47 | userForSession: (sessionToken) => call("user_for_session", { sessionToken }), | |
| 43 | 48 | userForGitCredentials: (username, secret) => | |
| 44 | 49 | call("user_for_git_credentials", { username, secret }), |
| 22 | 22 | ||
| 23 | 23 | export type AccessToken = { id: string; name: string; createdAt: number }; | |
| 24 | 24 | ||
| 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 | + | ||
| 25 | 42 | /** Accounts, credentials and sessions. */ | |
| 26 | 43 | export interface IdentityApi { | |
| 27 | 44 | /** Creates an account and signs it in. */ | |
| 39 | 56 | /** Sets a new password from an emailed token and ends every session. */ | |
| 40 | 57 | resetPassword(token: string, password: string): Promise<Result<User>>; | |
| 41 | 58 | ||
| 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 | + | ||
| 42 | 69 | userForSession(sessionToken: string): Promise<Viewer>; | |
| 43 | 70 | ||
| 44 | 71 | /** Verifies git credentials: the account password or an access token. */ |
| 6 | 6 | /** Routes and reserved words that may not be registered as usernames. */ | |
| 7 | 7 | const RESERVED = new Set([ | |
| 8 | 8 | "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", | |
| 10 | 10 | ]); | |
| 11 | 11 | ||
| 12 | 12 | export function isValidNamespace(value: string): boolean { |
| 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 | + | ); |
| 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 | + | } |
| 4 | 4 | //! the methods and their arguments. | |
| 5 | 5 | ||
| 6 | 6 | mod crypto; | |
| 7 | + | mod device; | |
| 7 | 8 | mod email; | |
| 8 | 9 | ||
| 9 | 10 | use g1t_contracts::identity::*; | |
| 549 | 550 | match method.as_str() { | |
| 550 | 551 | "register" => reply(&identity.register(args(body)?).await?), | |
| 551 | 552 | "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?), | |
| 552 | 557 | "resend_verification" => reply(&identity.resend_verification(args(body)?).await?), | |
| 553 | 558 | "verify_email" => reply(&identity.verify_email(args(body)?).await?), | |
| 554 | 559 | "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?), |