Native and CLI
CLIs, desktop apps, and mobile apps cannot keep a client secret — anything shipped to a user's
machine is readable. Register them as native clients, which Gate configures as public clients with
token_endpoint_auth_method: "none". PKCE is what protects the exchange instead.
Register the client
curl -sS -X POST \
"https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/oidc-clients" \
-H "content-type: application/json" \
-b "better-auth.session_token=$SESSION" \
-d '{
"name": "ShellTime CLI",
"type": "native",
"redirectUris": [
"http://127.0.0.1:8976/callback",
"shelltime://auth/callback"
],
"scopes": ["openid", "profile", "email", "offline_access"]
}'The response includes clientSecret: null for native clients — there is nothing to store. Ship the
clientId in your binary; it is not a secret.
Redirect strategy
| Platform | Redirect URI | Notes |
|---|---|---|
| CLI | http://127.0.0.1:<port>/callback | Bind an ephemeral port, register the exact URI you will use |
| macOS / iOS | yourapp://auth/callback | Use ASWebAuthenticationSession |
| Android | yourapp://auth/callback | Use Custom Tabs, not a WebView |
Register every URI you might use. Gate matches redirect URIs exactly, and a CLI that binds a random port will fail unless that exact URI was registered. Pick a small fixed set of ports and try them in order.
CLI: loopback flow
// 1. PKCE
verifierBytes := make([]byte, 32)
rand.Read(verifierBytes)
verifier := base64.RawURLEncoding.EncodeToString(verifierBytes)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
// 2. Listen before opening the browser, so the redirect cannot arrive early.
listener, err := net.Listen("tcp", "127.0.0.1:8976")
if err != nil {
return fmt.Errorf("port 8976 in use: %w", err)
}
defer listener.Close()
codes := make(chan string, 1)
go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
io.WriteString(w, "Signed in. You can close this window.")
codes <- r.URL.Query().Get("code")
}))
// 3. Open the system browser, then wait.
browser.OpenURL(authorizeURL)
select {
case code := <-codes:
return exchange(ctx, code, verifier)
case <-time.After(3 * time.Minute):
return errors.New("timed out waiting for the browser")
}import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const code = await new Promise<string>((resolve, reject) => {
const server = createServer((request, response) => {
const url = new URL(request.url!, "http://127.0.0.1:8976");
if (url.searchParams.get("state") !== state) {
response.writeHead(400).end("state mismatch");
return reject(new Error("state mismatch"));
}
response.end("Signed in. You can close this window.");
server.close();
resolve(url.searchParams.get("code")!);
});
server.listen(8976, "127.0.0.1");
});# Public clients send client_id in the body instead of using Basic auth.
curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/token \
-d grant_type=authorization_code \
-d client_id="$CLIENT_ID" \
-d code="$CODE" \
-d redirect_uri=http://127.0.0.1:8976/callback \
-d code_verifier="$VERIFIER"Mobile: ASWebAuthenticationSession
import AuthenticationServices
final class GateSignIn: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
func signIn(clientID: String) async throws -> TokenResponse {
let verifier = PKCE.makeVerifier()
let state = PKCE.makeState()
var components = URLComponents(
string: "https://evonia-gate.annatarhe.com/api/auth/oauth2/authorize"
)!
components.queryItems = [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: clientID),
.init(name: "redirect_uri", value: "rawback://auth/callback"),
.init(name: "scope", value: "openid profile email offline_access"),
.init(name: "state", value: state),
.init(name: "code_challenge", value: PKCE.challenge(for: verifier)),
.init(name: "code_challenge_method", value: "S256"),
]
let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: "rawback"
) { url, error in
if let url { continuation.resume(returning: url) }
else { continuation.resume(throwing: error ?? GateError.cancelled) }
}
// Share the system cookie jar so an existing Gate session is reused.
session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self
self.session = session
session.start()
}
let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
guard query?.first(where: { $0.name == "state" })?.value == state,
let code = query?.first(where: { $0.name == "code" })?.value
else { throw GateError.stateMismatch }
return try await exchange(code: code, verifier: verifier, clientID: clientID)
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
.first ?? ASPresentationAnchor()
}
}Storing the refresh token
The refresh token lives 30 days and is the most sensitive thing your app holds. The access token lives 600 seconds and can stay in memory.
| Platform | Store refresh tokens in |
|---|---|
| macOS / iOS | Keychain, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
| Android | EncryptedSharedPreferences or the Keystore |
| Linux / Windows CLI | The OS secret service, or a 0600 file under the user's config directory |
Refresh proactively — a few seconds before expires_in elapses — rather than waiting for a 401:
curl -sS -X POST https://evonia-gate.annatarhe.com/api/auth/oauth2/token \
-d grant_type=refresh_token \
-d client_id="$CLIENT_ID" \
-d refresh_token="$REFRESH_TOKEN"If refresh fails, discard both tokens and restart the flow. Do not retry a rejected refresh token.
Signing out
Delete the stored tokens, call /api/auth/oauth2/revoke with the refresh token so it cannot be
reused, and — if you want to clear the browser session too — open
/api/auth/oauth2/end-session.