Skip to content

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:

bash
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):

json
{
  "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_secret in the response — a public client has no secret at all.
  • client_type cannot 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).

Don't mix mobile + web + local redirects in one RP — split them:

jsonc
// 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.

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

  1. Generate code_verifier (43–128 chars of URL-safe random)
  2. code_challenge = BASE64URL(SHA256(code_verifier))
  3. /oauth/authorize → receive code at the callback
  4. Call /oauth/token (attaching code_verifier, without a client_secret)

Dart / Flutter

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

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

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

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

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

dart
// ❌ 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
swift
// ❌ If Info.plist $(LOGI_CLIENT_ID) is undefined, the literal is sent
kotlin
// ❌ BuildConfig.LOGI_CLIENT_ID = "REPLACE_ME"

Fix (one of two):

(A) Commit the real client_id to source (recommended)

dart
static const clientId = String.fromEnvironment('ONE_PASS_CLIENT_ID',
    defaultValue: 'logi_<your_public_client_id>');

(B) A fail-fast guard

dart
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

dart
body: { 'client_secret': '...' }  // ❌ invalid_client

3. An HTTP Basic header

dart
headers: {'Authorization': 'Basic ...'}  // ❌ invalid_client, by downgrade defense

4. Missing code_verifierinvalid_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 defense
  • spec/requests/oauth/redirect_uri_strictness_spec.rb — exact match

Reference

Identity가 제품의 신뢰를 만듭니다.