Clients
A client is an application allowed to ask for tokens. Which kind you register decides how it authenticates and which grants it may use.
The management page
A signed-in user manages their own clients at {route_prefix}/clients:
registering one, seeing what they have, and revoking it. It is a plain Go
template with no JavaScript, in the same style as the consent screen, so it
works whatever the application’s frontend is.
It lists their own clients and nobody else’s. That is what makes it safe without an administrator role — a page showing every client would leak the existence of every integration to whoever reached it. A client belonging to someone else is reported as “No such client” rather than “forbidden”, so the page cannot be used to find out which client ids exist.
A secret is shown exactly once, immediately after registration. It is stored hashed, so a refresh genuinely cannot recover it.
Set management_routes to false to turn it off, or supply ClientsView on
the provider to render it yourself.
The five kinds
Confidential — the default
A server-side application that can keep a secret.
lemmego run oauth:client \
--name "Acme Dashboard" \
--redirect-uri https://acme.example.com/callbackAuthenticates with its id and secret, by HTTP Basic or in the request body. Uses the authorization code grant with PKCE.
Public
A single-page application or a mobile app. Its code ships to the user, so it has no secret to keep.
lemmego run oauth:client --public \
--name "Acme Mobile" \
--redirect-uri com.acme.app:/oauth2redirectAuthenticates with its id alone, which is not a secret — so PKCE is what
binds an authorization code to the application that requested it, and is
required regardless of require_pkce. Sending a client_secret is rejected
rather than ignored, because it means the caller is confused about which
client it is.
The redirect above is a private-use URI scheme, which RFC 8252 §7.1
recommends for native applications: a reverse-domain scheme the operating
system routes back to the app. A loopback redirect such as
http://127.0.0.1:1234/callback also works, and its port is allowed to differ
at request time because a native app cannot know which port it will be given.
Machine to machine
No user is involved; the application acts as itself.
lemmego run oauth:client --client-credentials \
--name "Billing Sync" --scopes invoices:read,invoices:writeMust be confidential — a public client has no secret, so there would be nothing to authenticate. No refresh token is issued, per RFC 6749 §4.4.3: the client can always ask for another token with the credentials it already holds, so a refresh token would be a second credential with no extra capability and one more thing to leak.
Device
For anything that cannot open a browser.
lemmego run oauth:client --device --name "Acme CLI"See the device grant.
Personal access
The client personal access tokens are issued against. There is exactly one.
lemmego run oauth:client --personal --name "Personal Access Client"oauth_personal_access_clients table to record which
client this is. Lemmego gives it a fixed id instead, so a primary-key lookup
answers the same question with one fewer table and one fewer way to end up
inconsistent. That is why the schema has five tables where Passport has six.First-party clients
lemmego run oauth:client --first-party --name "Acme Web" --redirect-uri ...A client your own application owns. Consent is skipped for it — there is
nobody to ask — and it is the only kind that may request the * scope.
Redirect URIs
A redirect is matched by exact string equality. Not a prefix, not a subpath, no wildcards.
That is stricter than it may look, and it is deliberate: every relaxation of
this rule has been the root cause of a real token theft, and RFC 9700 §2.1
says simple string comparison. These all fail against a registered
https://app.example.com/callback:
https://app.example.com/callback/ a trailing slash
https://app.example.com/callback?x=1 an added query
https://app.example.com/callback/extra a subpath
https://app.example.com.evil.test/cb a suffixed host
https://app.example.com@evil.test/cb userinfo that reads as the hostThe only exception is a loopback address, where the port may differ.
Registration itself refuses a URI carrying a fragment, one with userinfo, a
relative URI, and plaintext http to anything but a loopback address.
http://localhost:1234/callback is not treated as loopback.
localhost resolves through the resolver and can be pointed elsewhere by a
hosts file or a DNS answer; 127.0.0.1 and [::1] cannot. RFC 8252 §8.3 says
to use the literal addresses.Limiting a client’s scopes
lemmego run oauth:client --name "Reports" --scopes reports:read --redirect-uri ...A client with a scope list may never be granted anything outside it, whatever it asks for. Leave it empty and the client may request any registered scope — subject, as always, to the user agreeing.
Secrets
A client secret is 32 bytes from crypto/rand, stored as its SHA-256 digest.
Comparison is constant-time, and a client that does not exist performs the same work as one with a wrong secret, so the endpoint cannot be used to learn which client ids are real.
Revoking a client
store.RevokeClient(ctx, clientID, time.Now())Revoking cascades to every token the client holds, at revoke time rather than on each later request — so an administrator pays the cost once instead of every request paying it forever.