Signing in from a page served off one's own machine
A `client_id` is a URL an authorization server fetches, and a dev server is not on the public web. The spec's way out is that a `client_id` whose origin is exactly `http://localhost` is not fetched at all: the server reads a virtual metadata document out of the query string. Exactly `localhost`. `127.0.0.1` is not accepted, which is what `Invalid client ID "http://127.0.0.1:8000/client-metadata.json"` was saying, and no port and no path either. The redirect URI declared in that query string is matched on its path but not on its port -- which is the point, a dev server's port being whatever was free -- so the redirect stays this page, address and port and all. Only the identity is the fiction. `nim/web/test/authorize.js` takes `FRQ_WEB_ORIGIN` now and pushes a real authorization request as either origin. bsky.social hands back a `request_uri` for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2d13303 parent: e099158 modified
flutter/web/frq_oauth.js +31 -2 | @@ -39,7 +39,36 @@ | ||
| 39 | 39 | // asks for `/index.htmlclient-metadata.json` and is told, quite correctly, |
| 40 | 40 | // Not Found. |
| 41 | 41 | const origin = () => window.location.origin + '/'; |
| 42 | - const clientId = () => origin() + 'client-metadata.json'; | |
| 42 | + | |
| 43 | + // Scope is declared in two places and they have to agree: here, where the | |
| 44 | + // authorization request asks for it, and in the metadata document, which | |
| 45 | + // says what this client may ever ask for. | |
| 46 | + const SCOPE = 'atproto transition:generic'; | |
| 47 | + | |
| 48 | + // A page served from a developer's own machine cannot publish a metadata | |
| 49 | + // document that an authorization server can reach, so the spec makes an | |
| 50 | + // exception for it: a `client_id` whose origin is exactly `http://localhost` | |
| 51 | + // is not fetched at all, and the server builds a virtual document out of the | |
| 52 | + // query string instead. | |
| 53 | + // | |
| 54 | + // Three things about that exception cost a rejection each to learn. The | |
| 55 | + // hostname must be the word `localhost` -- `127.0.0.1` is *not* accepted, | |
| 56 | + // which is exactly what `Invalid client ID "http://127.0.0.1:8000/..."` | |
| 57 | + // was saying. There must be no port and no path, so the `client_id` is | |
| 58 | + // `http://localhost` and nothing more before the `?`. And the redirect URI | |
| 59 | + // we declare there is matched on its path but *not* on its port, which is | |
| 60 | + // the whole point -- a dev server's port is whatever was free. | |
| 61 | + // | |
| 62 | + // So the redirect stays this page, loopback address and port and all; only | |
| 63 | + // the identity is the fiction. | |
| 64 | + const loopback = () => | |
| 65 | + /^(localhost|127(\.\d+){3}|\[::1\])$/.test(window.location.hostname); | |
| 66 | + | |
| 67 | + const clientId = () => | |
| 68 | + loopback() | |
| 69 | + ? 'http://localhost?redirect_uri=' + encodeURIComponent(origin()) + | |
| 70 | + '&scope=' + encodeURIComponent(SCOPE) | |
| 71 | + : origin() + 'client-metadata.json'; | |
| 43 | 72 | |
| 44 | 73 | const PENDING = 'frq:oauth:pending'; |
| 45 | 74 | const SESSION = 'frq:oauth:session'; |
| @@ -201,7 +230,7 @@ | ||
| 201 | 230 | ['client_id', clientId()], |
| 202 | 231 | ['redirect_uri', origin()], |
| 203 | 232 | ['response_type', 'code'], |
| 204 | - ['scope', 'atproto transition:generic'], | |
| 233 | + ['scope', SCOPE], | |
| 205 | 234 | ['state', state], |
| 206 | 235 | ['code_challenge', challenge], |
| 207 | 236 | ['code_challenge_method', 'S256'], |
| @@ -39,7 +39,36 @@ | |||
| 39 | // asks for `/index.htmlclient-metadata.json` and is told, quite correctly, | 39 | // asks for `/index.htmlclient-metadata.json` and is told, quite correctly, |
| 40 | // Not Found. | 40 | // Not Found. |
| 41 | const origin = () => window.location.origin + '/'; | 41 | const origin = () => window.location.origin + '/'; |
| 42 | - const clientId = () => origin() + 'client-metadata.json'; | 42 | + |
| 43 | + // Scope is declared in two places and they have to agree: here, where the | ||
| 44 | + // authorization request asks for it, and in the metadata document, which | ||
| 45 | + // says what this client may ever ask for. | ||
| 46 | + const SCOPE = 'atproto transition:generic'; | ||
| 47 | + | ||
| 48 | + // A page served from a developer's own machine cannot publish a metadata | ||
| 49 | + // document that an authorization server can reach, so the spec makes an | ||
| 50 | + // exception for it: a `client_id` whose origin is exactly `http://localhost` | ||
| 51 | + // is not fetched at all, and the server builds a virtual document out of the | ||
| 52 | + // query string instead. | ||
| 53 | + // | ||
| 54 | + // Three things about that exception cost a rejection each to learn. The | ||
| 55 | + // hostname must be the word `localhost` -- `127.0.0.1` is *not* accepted, | ||
| 56 | + // which is exactly what `Invalid client ID "http://127.0.0.1:8000/..."` | ||
| 57 | + // was saying. There must be no port and no path, so the `client_id` is | ||
| 58 | + // `http://localhost` and nothing more before the `?`. And the redirect URI | ||
| 59 | + // we declare there is matched on its path but *not* on its port, which is | ||
| 60 | + // the whole point -- a dev server's port is whatever was free. | ||
| 61 | + // | ||
| 62 | + // So the redirect stays this page, loopback address and port and all; only | ||
| 63 | + // the identity is the fiction. | ||
| 64 | + const loopback = () => | ||
| 65 | + /^(localhost|127(\.\d+){3}|\[::1\])$/.test(window.location.hostname); | ||
| 66 | + | ||
| 67 | + const clientId = () => | ||
| 68 | + loopback() | ||
| 69 | + ? 'http://localhost?redirect_uri=' + encodeURIComponent(origin()) + | ||
| 70 | + '&scope=' + encodeURIComponent(SCOPE) | ||
| 71 | + : origin() + 'client-metadata.json'; | ||
| 43 | 72 | ||
| 44 | const PENDING = 'frq:oauth:pending'; | 73 | const PENDING = 'frq:oauth:pending'; |
| 45 | const SESSION = 'frq:oauth:session'; | 74 | const SESSION = 'frq:oauth:session'; |
| @@ -201,7 +230,7 @@ | |||
| 201 | ['client_id', clientId()], | 230 | ['client_id', clientId()], |
| 202 | ['redirect_uri', origin()], | 231 | ['redirect_uri', origin()], |
| 203 | ['response_type', 'code'], | 232 | ['response_type', 'code'], |
| 204 | - ['scope', 'atproto transition:generic'], | 233 | + ['scope', SCOPE], |
| 205 | ['state', state], | 234 | ['state', state], |
| 206 | ['code_challenge', challenge], | 235 | ['code_challenge', challenge], |
| 207 | ['code_challenge_method', 'S256'], | 236 | ['code_challenge_method', 'S256'], |
modified
nim/web/test/authorize.js +7 -1 | @@ -16,8 +16,14 @@ | ||
| 16 | 16 | // login, and the request it pushes expires unused. |
| 17 | 17 | const fs = require('fs'); |
| 18 | 18 | let assigned = ''; |
| 19 | +// The deployed origin by default; `FRQ_WEB_ORIGIN=http://127.0.0.1:8000` runs | |
| 20 | +// the same probe as a page served from a developer's own machine, which takes | |
| 21 | +// the other branch of `clientId` -- the `http://localhost` exception. | |
| 22 | +const served = new URL(process.env.FRQ_WEB_ORIGIN || | |
| 23 | + 'https://codegod100--frq-web-serve.modal.run'); | |
| 19 | 24 | global.window = { |
| 20 | - location: { origin: 'https://codegod100--frq-web-serve.modal.run', pathname: '/', search: '', | |
| 25 | + location: { origin: served.origin, hostname: served.hostname, | |
| 26 | + pathname: '/', search: '', | |
| 21 | 27 | assign: (u) => { assigned = u; } }, |
| 22 | 28 | history: { replaceState() {} }, |
| 23 | 29 | }; |
| @@ -16,8 +16,14 @@ | |||
| 16 | // login, and the request it pushes expires unused. | 16 | // login, and the request it pushes expires unused. |
| 17 | const fs = require('fs'); | 17 | const fs = require('fs'); |
| 18 | let assigned = ''; | 18 | let assigned = ''; |
| 19 | +// The deployed origin by default; `FRQ_WEB_ORIGIN=http://127.0.0.1:8000` runs | ||
| 20 | +// the same probe as a page served from a developer's own machine, which takes | ||
| 21 | +// the other branch of `clientId` -- the `http://localhost` exception. | ||
| 22 | +const served = new URL(process.env.FRQ_WEB_ORIGIN || | ||
| 23 | + 'https://codegod100--frq-web-serve.modal.run'); | ||
| 19 | global.window = { | 24 | global.window = { |
| 20 | - location: { origin: 'https://codegod100--frq-web-serve.modal.run', pathname: '/', search: '', | 25 | + location: { origin: served.origin, hostname: served.hostname, |
| 26 | + pathname: '/', search: '', | ||
| 21 | assign: (u) => { assigned = u; } }, | 27 | assign: (u) => { assigned = u; } }, |
| 22 | history: { replaceState() {} }, | 28 | history: { replaceState() {} }, |
| 23 | }; | 29 | }; |