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
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

PlatformRedirect URINotes
CLIhttp://127.0.0.1:<port>/callbackBind an ephemeral port, register the exact URI you will use
macOS / iOSyourapp://auth/callbackUse ASWebAuthenticationSession
Androidyourapp://auth/callbackUse 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")
}

Mobile: ASWebAuthenticationSession

Swift
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.

PlatformStore refresh tokens in
macOS / iOSKeychain, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
AndroidEncryptedSharedPreferences or the Keystore
Linux / Windows CLIThe 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
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.