Skip to content

Public Clients

모바일 앱, SPA, CLI 등 client_secret 보관 불가 RP. RFC 6749 §2.1 + RFC 7636 (PKCE) + RFC 8252 (Native Apps). 트랙별 가이드: 모바일 · · API/CLI. 타입 선택: Public vs Confidential.

등록

웹 콘솔: https://start.1pass.dev/developer/applications/new → 클라이언트 타입 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"]
    }
  }'

응답 (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"
}
  • client_secret 응답에 없음 — public 은 secret 자체가 없음.
  • client_type 등록 후 변경 불가 → 필요 시 신규 RP.

redirect_uri 정책 (RFC 8252 §8.5)

허용:

  • https://...
  • http://localhost/..., http://127.0.0.1/..., http://[::1]/... (loopback)
  • com.example.app://... reverse-DNS custom scheme

외부 평문 http:// 는 거절. exact match (prefix/path/scheme 차이 모두 거부).

Surface 별 RP 분리 (권장)

한 RP 에 모바일+웹+로컬 redirect 를 섞지 말고 분리:

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"
]}

이유: iOS universal link 충돌(applinks: 도메인이 redirect 이면 무관한 link 가 SDK 에 missingCode 유발), public/confidential 정책 미스매치 방지, 권한 최소화.

OAuth callback 이 아님. logi 앱의 "Connected Apps" 카드에서 RP 앱 점프 시에만 사용. RP 가 universal link AASA 셋업된 경우에만 설정. 미설정해도 OAuth 정상 작동.

PKCE 흐름

  1. code_verifier 생성 (43~128자 URL-safe random)
  2. code_challenge = BASE64URL(SHA256(code_verifier))
  3. /oauth/authorize → callback 으로 code 수령
  4. /oauth/token 호출 (code_verifier 첨부, 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']!;

  // ⚠️ 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

매 refresh 마다 새 refresh token 발급. 이전 token 재사용 시 family 전체 revoke + 재로그인 강제.

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();  // 재사용 또는 만료
    }
  }

  // access_token + refresh_token 둘 다 새 값으로 덮어쓸 것
  return jsonDecode(resp.body) as Map<String, dynamic>;
}

⚠️ 새 refresh_token 을 반드시 storage 에 덮어쓸 것. 이전 값 유지 시 재사용으로 family 폐기.

흔한 실수

1. client_id 빌드 인자 주입 누락 (모바일 invalid_client 90% 원인)

Public client_id 는 secret 이 없어 소스 commit 해도 무해. 빌드 인자로 외주화 했을 때 인자 누락 시 placeholder 가 AOT/Info.plist/BuildConfig 에 그대로 박혀버림. 릴리스만 깨지고 dev 는 정상이라 발견 늦음.

dart
// ❌ defaultValue 가 바이너리에 박힘
static const clientId = String.fromEnvironment('ONE_PASS_CLIENT_ID',
    defaultValue: 'my-app-mobile');  // RP 이름은 client_id 가 아님
swift
// ❌ Info.plist $(LOGI_CLIENT_ID) 미정의 시 리터럴 전송
kotlin
// ❌ BuildConfig.LOGI_CLIENT_ID = "REPLACE_ME"

처방 (둘 중 하나):

(A) 진짜 client_id 를 소스에 commit (권장)

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

(B) Fail-fast 가드

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

빌드타임 상수이므로 런타임 ENV 변경 불가 → 코드 수정 → 재빌드 → 스토어 재업로드 필수.

2. client_secret 전송

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

3. HTTP Basic 헤더

dart
headers: {'Authorization': 'Basic ...'}  // ❌ downgrade 방어로 invalid_client

4. code_verifier 누락 → invalid_grant

5. redirect_uri 동적 변경 (authorize vs token 불일치) → invalid_grant

6. state 검증 생략 → CSRF 취약

Secret Downgrade 방어

Public client 가 client_secret 을 보내도 logi 는 secret 인증을 통과시키지 않음 (OauthApplication#authenticate_client_secretpublic_client? 면 무조건 false). 토큰 발급은 PKCE verifier 로만 성공. confidential 격하 공격 차단.

회귀 spec:

  • spec/integrations/krx_listing_rp_integration_spec.rb — downgrade defense
  • spec/requests/oauth/redirect_uri_strictness_spec.rb — exact match

참고

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