테마
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 정책 미스매치 방지, 권한 최소화.
app_link_url
OAuth callback 이 아님. logi 앱의 "Connected Apps" 카드에서 RP 앱 점프 시에만 사용. RP 가 universal link AASA 셋업된 경우에만 설정. 미설정해도 OAuth 정상 작동.
PKCE 흐름
code_verifier생성 (43~128자 URL-safe random)code_challenge = BASE64URL(SHA256(code_verifier))/oauth/authorize→ callback 으로code수령/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_client3. HTTP Basic 헤더
dart
headers: {'Authorization': 'Basic ...'} // ❌ downgrade 방어로 invalid_client4. 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_secret 가 public_client? 면 무조건 false). 토큰 발급은 PKCE verifier 로만 성공. confidential 격하 공격 차단.
회귀 spec:
spec/integrations/krx_listing_rp_integration_spec.rb— downgrade defensespec/requests/oauth/redirect_uri_strictness_spec.rb— exact match
참고
- RFC 6749 §2.1 · RFC 7636 · RFC 8252
- Refresh Token Rotation · Public vs Confidential
- 플랫폼 가이드: Flutter · Swift · Kotlin · React Native