테마
Universal Links 통합 가이드 (RP 측)
1pass IdP 와 OAuth 연동하는 RP 가 "1pass 로 로그인" 버튼을 눌렀을 때 native 앱으로 매끄럽게 핸드오프되도록 만드는 패턴.
핵심 제약
Apple Universal Links 는 Safari / WKWebView 안에서 클릭한 링크만 트리거된다.
| 환경 | Universal Link 트리거 |
|---|---|
| iOS Safari | ✅ 클릭 즉시 앱 실행 |
| macOS Safari | ⚠️ 페이지 로드 후 상단 "Open in [앱]" 배너 → 사용자가 한 번 더 클릭 |
| iOS / macOS WKWebView | ✅ |
| iOS / macOS Chrome / Firefox / Edge / Brave | ❌ (x-safari-https:// fallback) |
| Safari 시크릿 모드 | ⚠️ 일관되지 않음 |
| Windows / Android / Linux | ❌ (Android 는 App Links 별도) |
| 주소창 paste | ❌ (Apple 의도된 동작) |
| same-domain 클릭 (api.1pass.dev → api.1pass.dev) | ⚠️ iOS 완전 무시 (TN3155), macOS 약한 배너 — open.1pass.dev bouncer 로 우회 |
open.1pass.dev Universal Link bouncer
api.1pass.dev/session/new → api.1pass.dev/oauth/authorize same-host 클릭은 iOS 가 무시한다. 별도 호스트 open.1pass.dev/auth?... 가 AASA-claim 으로 우회한다. RP 측 추가 작업 불필요 — /oauth/authorize URL 만 박아두면 서버가 알아서 emit.
권장 3-tier 패턴
Tier 1 — 직접 <a href> (필수)
서버에서 OAuth authorize URL 을 미리 생성해 페이지에 직접 박는다. server-side 302 redirect 금지.
html
<a
href="https://api.1pass.dev/oauth/authorize?response_type=code&client_id=...&..."
data-turbo="false"
rel="external"
>
1pass 로 로그인
</a>data-turbo="false" (또는 프레임워크별 등가물) 로 SPA 라우터 가로채기 방지.
Tier 2 — non-Safari Apple OS 핸드오프 (권장)
html
<a
href="https://api.1pass.dev/oauth/authorize?..."
onclick="handleOnePassClick(event, this.href)"
data-turbo="false"
rel="external"
>
1pass 로 로그인
</a>
<script>
function handleOnePassClick(e, url) {
if (typeof navigator === 'undefined') return // SSR guard
const ua = navigator.userAgent
const isAppleOS = /iPhone|iPad|iPod|Macintosh|Mac OS X/.test(ua)
const isNonSafari = /CriOS|FxiOS|EdgiOS|Chrome|Firefox|Edg|OPR\//.test(ua)
if (isAppleOS && isNonSafari) {
e.preventDefault()
window.location.href = url.replace(/^https:/, 'x-safari-https:')
}
}
</script>x-safari-https:// 는 iOS / macOS Safari 가 자기 자신에게 등록한 비공식 scheme (iOS 14+ 안정). 비-Apple OS 에서는 그냥 실패하므로 안전.
Tier 3 — 1pass /session/new 안내 banner (자동)
Tier 2 실패 시 1pass 측이 자동으로 안내 banner 표시. RP 추가 작업 불필요.
조건: return_to 가 /oauth/authorize 시작 + UA 가 Apple OS + non-Safari.
구현 예시
Rails (Inertia.js + Svelte 5)
ruby
# app/controllers/web/sessions_controller.rb
def new
state = SecureRandom.urlsafe_base64(32)
verifier, challenge = OnePassSsoService.generate_pkce
session[:one_pass_state] = state
session[:one_pass_code_verifier] = verifier
session[:one_pass_initiated_at] = Time.current.to_i
render inertia: "Auth/Login", props: {
one_pass_authorize_url: OnePassSsoService.authorize_url(
redirect_uri: auth_1pass_callback_url,
state: state,
code_challenge: challenge
)
}
endsvelte
<!-- Login.svelte -->
<script lang="ts">
let { one_pass_authorize_url } = $props()
function handleOnePassClick(e: MouseEvent, url: string) {
if (typeof navigator === 'undefined') return
const ua = navigator.userAgent
const isAppleOS = /iPhone|iPad|iPod|Macintosh|Mac OS X/.test(ua)
const isNonSafari = /CriOS|FxiOS|EdgiOS|Chrome|Firefox|Edg|OPR\//.test(ua)
if (isAppleOS && isNonSafari) {
e.preventDefault()
window.location.href = url.replace(/^https:/, 'x-safari-https:')
}
}
</script>
<a
href={one_pass_authorize_url}
onclick={(e) => handleOnePassClick(e, one_pass_authorize_url)}
data-turbo="false"
rel="external"
class="..."
>
1pass 로 로그인
</a>Next.js (App Router)
tsx
'use client'
export function OnePassButton({ authorizeUrl }: { authorizeUrl: string }) {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
const ua = navigator.userAgent
const isAppleOS = /iPhone|iPad|iPod|Macintosh|Mac OS X/.test(ua)
const isNonSafari = /CriOS|FxiOS|EdgiOS|Chrome|Firefox|Edg|OPR\//.test(ua)
if (isAppleOS && isNonSafari) {
e.preventDefault()
window.location.href = authorizeUrl.replace(/^https:/, 'x-safari-https:')
}
}
return (
<a href={authorizeUrl} onClick={handleClick} rel="external">
1pass 로 로그인
</a>
)
}서버 page.tsx 에서 authorizeUrl 생성, PKCE verifier 는 cookie / server session 저장.
RP 검증 체크리스트
- [ ] Safari (iOS / macOS) → 앱이 native consent 화면으로 열림
- [ ] Mac Chrome → Safari 핸드오프 → 앱 열림 (Tier 2)
- [ ] iOS Chrome (CriOS) → Safari 핸드오프 → 앱 열림 (Tier 2)
- [ ] 앱 미설치 + Safari → 1pass 웹 로그인 페이지 (정상)
- [ ] Windows / Android Chrome → 1pass 웹 로그인 페이지 (정상)
- [ ] 시크릿 모드 / Safari 비활성화 → Tier 3 안내 banner
진단: 영향받는 디바이스에서 https://api.1pass.dev/diagnose 로 UA · in-app browser · AASA path claim · 테스트 deep-link 확인.
흔한 실수
❌ Server-side 302 redirect 로 OAuth flow 시작
<a href="/auth/1pass"> → server 가 redirect_to "https://api.1pass.dev/oauth/authorize?..." 302. macOS Universal Links 는 user-gesture 가 직접 향하는 도메인만 매칭 — redirect chain 은 user-gesture 를 끊는다.
해결: 페이지 렌더 시점에 server 에서 OAuth URL 을 미리 생성해 <a href> 에 박는다. PKCE verifier 는 server session 그대로 저장.
❌ 주소창 paste 로 검증
Apple 의도된 동작으로 Universal Link 무시. 반드시 다른 도메인 페이지에서 <a> 클릭 으로 테스트.
❌ Chrome 에서만 테스트
Safari 에서만 트리거. Safari 정상 모드에서 검증.
❌ x-safari-https:// 만 신뢰
비공식 + Apple 차단 가능. Tier 3 (1pass 안내 banner) 안전망 필수.
❌ applinks 도메인이 OAuth 콜백 호스트와 겹침
증상: RP 가 applinks:api.1pass.dev 를 entitlement 에 선언 → SDK 가 무관한 universal link 를 consume 해 missingCode / invalidCallback 에러.
해결:
- LogiAuth Swift 0.1.2+ 업그레이드 — pending handoff 동안 SDK 가 redirect_uri scheme/host/path 검증
- RP
applinks:claim 도메인을 OAuth 콜백 호스트 (api.1pass.dev) 와 분리 — RP 자기 도메인만 claim, callback 은 custom URL scheme (com.example.app://oauth/callback) /diagnose로 AASA path claim 충돌 확인
❌ Universal Link 가 직접 /oauth/authorize 를 가리킴
앱 미설치 사용자에게 브라우저가 authorize URL 그대로 열어버림 → client_id / redirect_uri / code_challenge 미리 준비 안 됐으면 invalid_request.
해결: Universal Link 는 trampoline 라우트 (예: /auth/1pass/start) 를 가리키게 한다.
- 앱 설치 → AASA 매칭으로 앱 열림, trampoline HTTP 응답 무시
- 앱 미설치 → trampoline 이 HTML 로 "앱 다운로드 안내" 또는 PKCE 준비된 web OAuth fallback
macOS Safari "Open in [App]" 배너가 안 뜰 때
- Mac 앱이
/Applications/정식 설치 + 한 번 이상 실행되어 Developer ID first-launch gate 통과 - 같은 앱 두 카피 (TestFlight + Xcode debug) 동시 설치 금지 — Apple "한 Mac 에 한 카피"
- AASA 캐시 stale → 앱 재설치
curl https://api.1pass.dev/.well-known/apple-app-site-association가MAC_APP_ID(74PTNNLD4P.com.dcodelabs.logi.mac) 포함하는지 확인