Public Clients
Mobile apps, SPAs, CLIs, and other RPs that cannot store a client_secret. RFC 6749 §2.1 + RFC 7636 (PKCE) + RFC 8252 (Native Apps). Per-track guides: Mobile · Web · API/CLI. Type selection: Public vs Confidential.
Registration
Web console: https://start.1pass.dev/developer/applications/new → choose client type Public.
API:
curl -X POST https://api.1pass.dev/api/v1/applications \
-H "Authorization: Bearer $PERSONAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"application": {
"name": "My Mobile App",
"client_type": "public",
"redirect_uris": [
"https://app.example.com/auth/callback",
"com.example.app://auth/callback"
],
"app_link_url": "https://app.example.com",
"allowed_scopes": ["openid", "profile:basic", "email"]
}
}'Response (201):
{
"id": 42,
"client_id": "logi_<32-hex>",
"client_type": "public",
"token_endpoint_auth_method": "none",
"redirect_uris": ["https://app.example.com/auth/callback", "com.example.app://auth/callback"],
"allowed_scopes": ["openid", "profile:basic", "email"],
"status": "approved"
}- No
client_secretin the response — a public client has no secret at all. client_typecannot be changed after registration → register a new RP if needed.
redirect_uri policy (RFC 8252 §8.5)
Allowed:
https://...http://localhost/...,http://127.0.0.1/...,http://[::1]/...(loopback)com.example.app://...reverse-DNS custom scheme
External plaintext http:// is rejected. Exact match (differences in prefix/path/scheme are all rejected).
Splitting RPs by surface (recommended)
Don't mix mobile + web + local redirects in one RP — split them:
// myapp-mobile
{ "redirect_uris": ["myapp://oauth/1pass/callback"], "app_link_url": null }
// myapp-web
{ "redirect_uris": [
"https://app.example.com/auth/1pass/callback",
"http://localhost:3000/auth/1pass/callback"
]}Why: to avoid iOS universal link conflicts (if an applinks: domain is also a redirect, an unrelated link can cause missingCode in the SDK), to prevent public/confidential policy mismatches, and to minimize privilege.
app_link_url
This is not an OAuth callback. It is used only to jump to the RP app from the "Connected Apps" card in the logi app. Set it only if the RP has a universal link AASA setup. OAuth works fine without it.
PKCE flow
- Generate
code_verifier(43–128 chars of URL-safe random) code_challenge = BASE64URL(SHA256(code_verifier))/oauth/authorize→ receivecodeat the callback- Call
/oauth/token(attachingcode_verifier, without a client_secret)
Dart / Flutter
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
String _randomString(int length) {
final rand = Random.secure();
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
return List.generate(length, (_) => chars[rand.nextInt(chars.length)]).join();
}
String _s256(String verifier) =>
base64UrlEncode(sha256.convert(utf8.encode(verifier)).bytes).replaceAll('=', '');
Future<Map<String, dynamic>> signInWithLogi() async {
const issuer = 'https://api.1pass.dev';
const clientId = 'logi_<your_public_client_id>';
const redirectUri = 'com.example.app://auth/callback';
final state = _randomString(32);
final verifier = _randomString(64);
final challenge = _s256(verifier);
final authUrl = Uri.parse('$issuer/oauth/authorize').replace(queryParameters: {
'response_type': 'code',
'client_id': clientId,
'redirect_uri': redirectUri,
'scope': 'openid profile:basic email',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
});
final callback = await FlutterWebAuth2.authenticate(
url: authUrl.toString(),
callbackUrlScheme: Uri.parse(redirectUri).scheme,
);
final cb = Uri.parse(callback);
if (cb.queryParameters['state'] != state) throw 'state mismatch';
final code = cb.queryParameters['code']!;
// ⚠️ No client_secret — public client.
final tokenResp = await http.post(
Uri.parse('$issuer/oauth/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': redirectUri,
'client_id': clientId,
'code_verifier': verifier,
},
);
return jsonDecode(tokenResp.body) as Map<String, dynamic>;
}JavaScript / TypeScript (SPA)
async function signInWithLogi() {
const issuer = 'https://api.1pass.dev';
const clientId = 'logi_<your_public_client_id>';
const redirectUri = `${window.location.origin}/auth/callback`;
const state = crypto.randomUUID();
const verifier = base64url(crypto.getRandomValues(new Uint8Array(48)));
const challenge = base64url(new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
));
sessionStorage.setItem('logi_verifier', verifier);
sessionStorage.setItem('logi_state', state);
const authUrl = new URL(`${issuer}/oauth/authorize`);
authUrl.search = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid profile:basic email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
window.location.href = authUrl.toString();
}
// /auth/callback:
async function exchangeCode() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (state !== sessionStorage.getItem('logi_state')) throw 'state mismatch';
const verifier = sessionStorage.getItem('logi_verifier');
sessionStorage.removeItem('logi_verifier');
sessionStorage.removeItem('logi_state');
const resp = await fetch('https://api.1pass.dev/oauth/token', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code!,
redirect_uri: `${window.location.origin}/auth/callback`,
client_id: 'logi_<your_public_client_id>',
code_verifier: verifier!,
}),
});
return resp.json();
}
function base64url(bytes: Uint8Array | string): string {
const arr = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
return btoa(String.fromCharCode(...arr))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}Swift (iOS native)
import AuthenticationServices
import CryptoKit
func signInWithLogi() async throws -> [String: Any] {
let issuer = "https://api.1pass.dev"
let clientId = "logi_<your_public_client_id>"
let redirectUri = "com.example.app://auth/callback"
let state = UUID().uuidString
let verifier = randomString(64)
let challenge = sha256Base64Url(verifier)
var components = URLComponents(string: "\(issuer)/oauth/authorize")!
components.queryItems = [
.init(name: "response_type", value: "code"),
.init(name: "client_id", value: clientId),
.init(name: "redirect_uri", value: redirectUri),
.init(name: "scope", value: "openid profile:basic email"),
.init(name: "state", value: state),
.init(name: "code_challenge", value: challenge),
.init(name: "code_challenge_method", value: "S256"),
]
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: "com.example.app"
) { _, _ in }
let callbackUrl: URL = try await startAuth(session)
let queryItems = URLComponents(url: callbackUrl, resolvingAgainstBaseURL: false)?.queryItems ?? []
let returnedState = queryItems.first(where: { $0.name == "state" })?.value
guard returnedState == state else { throw OAuthError.stateMismatch }
let code = queryItems.first(where: { $0.name == "code" })?.value ?? ""
var req = URLRequest(url: URL(string: "\(issuer)/oauth/token")!)
req.httpMethod = "POST"
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
req.httpBody = "grant_type=authorization_code&code=\(code)&redirect_uri=\(redirectUri)&client_id=\(clientId)&code_verifier=\(verifier)"
.data(using: .utf8)
let (data, _) = try await URLSession.shared.data(for: req)
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
}
private func sha256Base64Url(_ s: String) -> String {
let hash = SHA256.hash(data: Data(s.utf8))
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}Kotlin (Android native)
import androidx.browser.customtabs.CustomTabsIntent
import okhttp3.*
import java.security.MessageDigest
class LogiAuth(private val context: Context) {
private val issuer = "https://api.1pass.dev"
private val clientId = "logi_<your_public_client_id>"
private val redirectUri = "com.example.app://auth/callback"
fun startAuth() {
val verifier = randomString(64)
val challenge = sha256Base64Url(verifier)
val state = UUID.randomUUID().toString()
context.encryptedPrefs().edit()
.putString("logi_verifier", verifier)
.putString("logi_state", state)
.apply()
val authUrl = Uri.parse("$issuer/oauth/authorize").buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUri)
.appendQueryParameter("scope", "openid profile:basic email")
.appendQueryParameter("state", state)
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authUrl)
}
suspend fun exchangeCode(callbackUri: Uri): Map<String, Any> {
val code = callbackUri.getQueryParameter("code")!!
val state = callbackUri.getQueryParameter("state")
val prefs = context.encryptedPrefs()
require(state == prefs.getString("logi_state", null)) { "state mismatch" }
val verifier = prefs.getString("logi_verifier", null)!!
prefs.edit().remove("logi_verifier").remove("logi_state").apply()
val body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", code)
.add("redirect_uri", redirectUri)
.add("client_id", clientId)
.add("code_verifier", verifier)
.build()
val req = Request.Builder().url("$issuer/oauth/token").post(body).build()
val resp = OkHttpClient().newCall(req).execute()
return JSONObject(resp.body!!.string()).toMap()
}
}Refresh Token Rotation
A new refresh token is issued on every refresh. Reusing a previous token revokes the entire family and forces re-login.
Future<Map<String, dynamic>> refresh(String refreshToken) async {
final resp = await http.post(
Uri.parse('https://api.1pass.dev/oauth/token'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
'client_id': 'logi_<your_public_client_id>',
},
);
if (resp.statusCode == 400) {
final err = jsonDecode(resp.body);
if (err['error'] == 'invalid_grant') {
throw ReAuthRequired(); // reuse or expiry
}
}
// Overwrite both access_token + refresh_token with the new values
return jsonDecode(resp.body) as Map<String, dynamic>;
}⚠️ Always overwrite the new refresh_token in storage. Keeping the old value counts as reuse and revokes the family.
Common mistakes
1. Forgetting to inject the client_id build argument (90% of mobile invalid_client cases)
A public client_id has no secret, so committing it to source is harmless. When externalized as a build argument, a missing argument bakes the placeholder straight into the AOT/Info.plist/BuildConfig. Only the release build breaks while dev works fine, so it's discovered late.
// ❌ The defaultValue gets baked into the binary
static const clientId = String.fromEnvironment('ONE_PASS_CLIENT_ID',
defaultValue: 'my-app-mobile'); // an RP name is not a client_id// ❌ If Info.plist $(LOGI_CLIENT_ID) is undefined, the literal is sent// ❌ BuildConfig.LOGI_CLIENT_ID = "REPLACE_ME"Fix (one of two):
(A) Commit the real client_id to source (recommended)
static const clientId = String.fromEnvironment('ONE_PASS_CLIENT_ID',
defaultValue: 'logi_<your_public_client_id>');(B) A fail-fast guard
class LogiConfig {
static const _raw = String.fromEnvironment('ONE_PASS_CLIENT_ID',
defaultValue: 'REPLACE_WITH_LOGI_CLIENT_ID');
static String get clientId {
if (!_raw.startsWith('logi_')) {
throw StateError('logi client_id not injected at build time');
}
return _raw;
}
}Because it's a build-time constant, it can't be changed via a runtime ENV → you must edit the code → rebuild → re-upload to the store.
2. Sending a client_secret
body: { 'client_secret': '...' } // ❌ invalid_client3. An HTTP Basic header
headers: {'Authorization': 'Basic ...'} // ❌ invalid_client, by downgrade defense4. Missing code_verifier → invalid_grant
5. Dynamically changing redirect_uri (authorize vs token mismatch) → invalid_grant
6. Skipping state validation → vulnerable to CSRF
Secret downgrade defense
Even if a public client sends a client_secret, logi does not let it pass secret authentication (OauthApplication#authenticate_client_secret is unconditionally false when public_client?). Token issuance succeeds only with the PKCE verifier. This blocks confidential-downgrade attacks.
Regression specs:
spec/integrations/krx_listing_rp_integration_spec.rb— downgrade defensespec/requests/oauth/redirect_uri_strictness_spec.rb— exact match
Reference
- RFC 6749 §2.1 · RFC 7636 · RFC 8252
- Refresh Token Rotation · Public vs Confidential
- Platform guides: Flutter · Swift · Kotlin · React Native