Standalone App Guide — Public Client + PKCE

— OAUTH 2.1 · AUTHORIZATION CODE · PKCE (S256) —

Sign users in to a standalone app, a mobile or desktop app that has no backend server (Flutter, iOS, Android, Node, Electron). The app never holds a client_secret. It opens the AltsCodex login page in the system browser, receives an authorization code on its redirect URI, and exchanges it for a slot access token using PKCE. 백엔드 서버가 없는 스탠드얼론 앱(모바일·데스크톱. Flutter, iOS, Android, Node, Electron)에서 AltsCodex 계정으로 로그인하는 방법입니다. 앱은 client_secret 을 갖지 않습니다. 시스템 브라우저로 AltsCodex 로그인 페이지를 열고, redirect URI 로 인가 코드를 받은 뒤 PKCE 로 슬롯 access token 과 교환합니다.

Intro What is a standalone app? 스탠드얼론 앱이란?

A standalone app is a mobile or desktop app that talks to AltsCodex directly, with no backend server of its own. Everything it ships can be extracted from the binary, so it cannot keep a client_secret. It is registered as a public client and proves that the same app started and finished the login with PKCE (S256) instead of a secret. The app never receives the platform session JWT. It receives only its own app access token and refresh token. 스탠드얼론 앱은 자체 백엔드 서버 없이 AltsCodex 와 직접 통신하는 모바일·데스크톱 앱입니다. 앱에 넣은 값은 바이너리에서 모두 추출할 수 있으므로 client_secret 을 보관할 수 없습니다. 그래서 공개 클라이언트로 등록하고, 시크릿 대신 PKCE (S256) 로 로그인을 시작한 앱과 끝낸 앱이 같은지 증명합니다. 앱은 플랫폼 세션 JWT 를 받지 않고, 앱 전용 access token 과 refresh token 만 받습니다.

Which integration should I use? 언제 무엇을 쓰나

Web SSO웹 SSO Standalone (this page)스탠드얼론 (이 페이지) Agent delegation에이전트 위임
For대상 Web app with a backend백엔드가 있는 웹 앱 Mobile or desktop app with no backend백엔드가 없는 모바일·데스크톱 앱 Service that accepts calls from AI agents acting for a user사용자를 대신하는 AI 에이전트의 호출을 받는 서비스
Client type클라이언트 종류 Confidential client기밀 클라이언트 Public client + PKCE (S256)공개 클라이언트 + PKCE (S256) Your backend verifies tokens issued to the agent에이전트가 받은 토큰을 내 백엔드가 검증
client_secret Yes, kept on the backend있음. 백엔드에 보관 No없음 Not used for verification검증에 쓰지 않음
Tokens받는 토큰 Platform JWT, via popup and backend code exchange팝업과 백엔드 코드 교환으로 받는 플랫폼 JWT App-only access token and refresh token앱 전용 access token·refresh token Agent delegation token, checked with verifyAgentToken에이전트 위임 토큰. verifyAgentToken 으로 검증
Guide가이드 SDK Guide (Frontend) · SDK Guide (Backend) Standalone App Guide Agent Delegation Guide

Tools by platform 플랫폼별 도구

PlatformLibraryExample예제
Flutter · iOS · Android AppAuth (flutter_appauth, AppAuth-iOS, AppAuth-Android)AppAuth (flutter_appauth, AppAuth-iOS, AppAuth-Android) Section 77절
JS · Node · Electron @altscodex/sdk/native (AltsCodexNative) and @altscodex/sdk/native/node (loginWithLoopback), npm @altscodex/sdk 4.0.0 or later@altscodex/sdk/native(AltsCodexNative)·@altscodex/sdk/native/node(loginWithLoopback), npm @altscodex/sdk 4.0.0 이상 Section 7, Desktop7절 Desktop

1 Register a public client 공개 클라이언트 등록

  1. In Register App, choose Mobile / Standalone (Public Client, PKCE) as the application type. Register App 에서 앱 종류를 모바일·스탠드얼론 (공개 클라이언트, PKCE) 로 선택합니다.
  2. Register every redirect URI your app uses. Public clients accept https:// URIs (App Links / Universal Links) or a custom scheme. http:// is rejected, except the desktop loopback URI below. Use a reverse-domain custom scheme such as com.example.app:/callback. 앱이 쓰는 redirect URI 를 모두 등록합니다. 공개 클라이언트는 https:// URI(앱 링크·유니버설 링크) 또는 커스텀 스킴만 허용하며 http:// 는 아래 데스크톱 루프백 URI 를 제외하고 거부됩니다. 커스텀 스킴은 com.example.app:/callback 처럼 역도메인 형식을 쓰세요.
  3. Desktop apps (Node / Electron) register the loopback URI http://127.0.0.1/callback without a port. The app listens on a random port at run time, and the server ignores the port when matching, so one registration covers every run. Only the IP literals 127.0.0.1 and [::1] are accepted. localhost is rejected. 데스크톱 앱(Node·Electron)은 루프백 URI http://127.0.0.1/callback 을 포트 없이 등록합니다. 앱은 실행할 때마다 임의 포트로 대기하고, 서버는 비교할 때 포트를 무시하므로 한 번 등록하면 모든 실행에 맞습니다. IP 리터럴 127.0.0.1 과 [::1] 만 허용하며 localhost 는 거부됩니다.
  4. To change the registered information later, open My Applications → Edit. Redirect URI changes apply to login immediately. 등록 후 정보 수정은 My Applications → Edit 에서 합니다. redirect URI 변경은 저장 즉시 로그인에 적용됩니다.
  5. Use only the client_id in your app. 앱에는 client_id 만 넣습니다.
⚠️ Never embed a client_secret in an app ⚠️ client_secret 을 앱에 절대 넣지 마세요
Anything shipped inside an APK, IPA, or desktop binary can be extracted. Even if the developer center shows a client_secret for your application, a public client must not use it. PKCE replaces the secret for the token exchange. APK·IPA·데스크톱 바이너리 안에 넣은 값은 누구나 추출할 수 있습니다. 개발자센터에 client_secret 이 표시되더라도 공개 클라이언트는 이를 쓰지 않습니다. 토큰 교환에서는 PKCE 가 secret 역할을 대신합니다.

2 Endpoints 엔드포인트

NameURL
issuer https://api.altscodex.com
Discovery (OpenID Connect) https://api.altscodex.com/.well-known/openid-configuration
Discovery (RFC 8414) https://api.altscodex.com/.well-known/oauth-authorization-server
authorization_endpoint https://www.altscodex.com/oauth/login
token_endpoint https://api.altscodex.com/v1/oauth2/token
revocation_endpoint https://api.altscodex.com/v1/oauth2/revoke
jwks_uri https://api.altscodex.com/.well-known/jwks.json

Both discovery paths (/.well-known/openid-configuration and /.well-known/oauth-authorization-server) return the same document. AppAuth's issuer-based discovery reads /.well-known/openid-configuration. 두 디스커버리 경로(/.well-known/openid-configuration, /.well-known/oauth-authorization-server)는 같은 문서를 반환합니다. AppAuth 의 issuer 기반 탐색은 /.well-known/openid-configuration 을 읽습니다.

{
  "issuer": "https://api.altscodex.com",
  "authorization_endpoint": "https://www.altscodex.com/oauth/login",
  "token_endpoint": "https://api.altscodex.com/v1/oauth2/token",
  "revocation_endpoint": "https://api.altscodex.com/v1/oauth2/revoke",
  "jwks_uri": "https://api.altscodex.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": []
}
ℹ️ No id_token is issued ℹ️ id_token 은 발급하지 않습니다
AltsCodex does not issue an id_token. The jwks_uri, subject_types_supported and id_token_signing_alg_values_supported fields exist only so that OpenID Connect discovery parsers such as AppAuth accept the document, and /.well-known/jwks.json returns an empty key list ({"keys": []}). The openid scope is ignored if requested. Use the slot field of the token response for the signed-in account. AltsCodex 는 id_token 을 발급하지 않습니다. jwks_uri, subject_types_supported, id_token_signing_alg_values_supported 필드는 AppAuth 같은 OpenID Connect 디스커버리 파서가 문서를 받아들이도록 넣은 값이며, /.well-known/jwks.json 은 빈 키 목록({"keys": []})을 반환합니다. openid scope 를 요청해도 무시됩니다. 로그인한 계정 정보는 토큰 응답의 slot 필드를 쓰세요.

3 Authorization request 인가 요청

GET https://www.altscodex.com/oauth/login
ParameterRequiredDescription
client_id Required Client ID issued by the developer center. 개발자센터에서 발급받은 클라이언트 ID.
redirect_uri Required Must exactly match a registered redirect URI. 등록한 redirect URI 와 정확히 일치해야 합니다.
response_type Required code
state Required Random value. The app must check that the returned state matches. 무작위 값. 돌아온 state 가 보낸 값과 같은지 앱이 확인해야 합니다.
code_challenge Required BASE64URL(SHA256(code_verifier))
code_challenge_method Required S256 (only S256 is supported) (S256 만 지원)

On success the browser returns to redirect_uri?code=...&state=.... The code expires in 60 seconds and can be used once. If the user denies consent, the app receives redirect_uri?error=access_denied&state=.... 성공하면 브라우저가 redirect_uri?code=...&state=... 로 돌아옵니다. code 는 60초 뒤 만료되며 한 번만 쓸 수 있습니다. 사용자가 동의를 거부하면 redirect_uri?error=access_denied&state=... 가 전달됩니다.

4 Token exchange 토큰 교환

POST https://api.altscodex.com/v1/oauth2/token

Send application/x-www-form-urlencoded (what AppAuth sends) or application/json. No Authorization header. application/x-www-form-urlencoded(AppAuth 기본) 또는 application/json 으로 보냅니다. Authorization 헤더는 없습니다.

grant_type=authorization_code
&client_id=YOUR_CLIENT_ID
&code=AUTHORIZATION_CODE
&redirect_uri=com.example.app:/callback
&code_verifier=ORIGINAL_CODE_VERIFIER

Success response (HTTP 200). slot is an AltsCodex extension field describing the signed-in alt slot. 성공 응답(HTTP 200). slot 은 로그인한 부계정 슬롯 정보를 담은 AltsCodex 확장 필드입니다.

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 86400,
  "refresh_token": "...",
  "slot": {
    "id": 42,
    "token_nickname": "...",
    "content_address": "...",
    "tr_cnt": 0,
    "lease": { "state": "none", "expires_at": null }
  }
}

Error response (HTTP 400, RFC 6749 §5.2). error is one of invalid_grant, invalid_request, invalid_client, unsupported_grant_type. 실패 응답(HTTP 400, RFC 6749 §5.2 형식). error 는 invalid_grant, invalid_request, invalid_client, unsupported_grant_type 중 하나입니다.

{ "error": "invalid_grant", "error_description": "..." }
ℹ️ Reading slot with AppAuth ℹ️ AppAuth 에서 slot 읽기
AppAuth exposes non-standard fields as additional parameters. AppAuth-Android delivers slot as a JSON string, AppAuth-iOS as a dictionary. In Flutter, handle both shapes. AppAuth 는 표준 외 필드를 additional parameters 로 넘깁니다. AppAuth-Android 는 slot 을 JSON 문자열로, AppAuth-iOS 는 딕셔너리로 줍니다. Flutter 에서는 두 형태를 모두 처리하세요.

5 Refresh token rotation 리프레시 토큰 회전

POST https://api.altscodex.com/v1/oauth2/token

grant_type=refresh_token
&client_id=YOUR_CLIENT_ID
&refresh_token=CURRENT_REFRESH_TOKEN

Every refresh returns a new access_token and a new refresh_token. The previous refresh_token is invalidated immediately, so always store the new one. The access token lives 86400 seconds (24 hours) and the refresh token 100 days by default. 리프레시할 때마다 새 access_token 과 새 refresh_token 이 발급됩니다. 이전 refresh_token 은 즉시 무효가 되므로 항상 새 값을 저장하세요. 기본 수명은 access token 86400초(24시간), refresh token 100일입니다.

6 Sign out (revoke) 로그아웃 (revoke)

POST https://api.altscodex.com/v1/oauth2/revoke

token=REFRESH_OR_ACCESS_TOKEN
&token_type_hint=refresh_token
&client_id=YOUR_CLIENT_ID

Revocation invalidates both the access and refresh tokens of this app's slot and marks the slot as signed out. It always returns HTTP 200. The user's AltsCodex platform session in the browser is not affected, so other apps and the next login ("continue with this account") keep working. Delete the stored tokens on the device as well. revoke 는 이 앱 슬롯의 access·refresh 토큰을 모두 무효화하고 슬롯을 로그아웃 상태로 바꿉니다. 응답은 항상 HTTP 200 입니다. 브라우저에 있는 AltsCodex 플랫폼 세션은 유지되므로 다른 앱과 다음 로그인("이 계정으로 계속")은 그대로 동작합니다. 기기에 저장한 토큰도 함께 지우세요.

7 Platform examples 플랫폼별 예시

All three libraries generate state and the PKCE code_verifier / code_challenge (S256) automatically and open the system browser. The examples use discovery (issuer https://api.altscodex.com or /.well-known/openid-configuration). Each ends with an alternative that passes the endpoints directly. 세 라이브러리 모두 state 와 PKCE code_verifier·code_challenge(S256)를 자동 생성하고 시스템 브라우저를 엽니다. 예시는 디스커버리(issuer https://api.altscodex.com 또는 /.well-known/openid-configuration)를 쓰며, 각 예시 끝에 엔드포인트를 직접 지정하는 대안을 남겼습니다.

A integrates with the official Android SDK and is the recommended path, and B walks through a complete AppAuth-Android 0.11.1 integration that you implement yourself, from registration to sign-out. Steps 1 to 7 explain each decision, step 8 helps you find where a login fails, and step 9 puts everything into one Kotlin sample. If you move to a different AppAuth version, compile and test the sample again. A 는 공식 Android SDK 로 연동하는 권장 방법이고, B 는 등록부터 로그아웃까지 AppAuth-Android 0.11.1 연동 전체를 직접 구현하는 방법입니다. 1~7단계는 단계별 판단 기준, 8단계는 로그인이 어디서 실패하는지 찾는 방법, 9단계는 전체를 담은 Kotlin 샘플입니다. 다른 AppAuth 버전으로 바꾸면 샘플을 다시 컴파일하고 테스트하세요.

A. Integrate with the SDK (recommended) A. SDK 로 연동(권장)

The official Android SDK com.github.banstorm:altscodex-aos-sdk applies the rules described in B for you. It is published on JitPack under the MIT license, and the source is at github.com/banstorm/altscodex-aos-sdk. Use the client_id and redirect URI you registered in section 1, exactly as registered. 공식 Android SDK com.github.banstorm:altscodex-aos-sdk 는 B 에서 설명하는 규칙을 대신 처리합니다. JitPack 으로 배포되며 MIT 라이선스이고, 소스는 github.com/banstorm/altscodex-aos-sdk 에 있습니다. 1절에서 등록한 client_id 와 redirect URI 를 등록한 값 그대로 씁니다.

Install 설치

Add the JitPack repository and the dependency. appAuthRedirectScheme is the scheme part only of the redirect URI. The SDK requires minSdk 26 and brings in AppAuth-Android 0.11.1 as an api dependency, so you do not need to add AppAuth separately. JitPack 저장소와 의존성을 추가합니다. appAuthRedirectScheme 에는 redirect URI 의 스킴 부분만 넣습니다. SDK 는 minSdk 26 이상이 필요하고 AppAuth-Android 0.11.1 을 api 의존성으로 함께 가져오므로 AppAuth 를 따로 추가할 필요가 없습니다.

// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://jitpack.io")
    }
}

// app/build.gradle.kts
android {
    defaultConfig {
        minSdk = 26
        // Scheme part only of the redirect URI (com.example.app:/callback becomes com.example.app)
        manifestPlaceholders["appAuthRedirectScheme"] = "com.example.app"
    }
}

dependencies {
    implementation("com.github.banstorm:altscodex-aos-sdk:0.1.0") // includes AppAuth-Android 0.11.1
}

Usage 사용 예

Create one AltsCodexAuth per app and reuse it. Register the result launcher in onCreate every time, so the result still arrives after the Activity is recreated or the process restarts. Show every AuthResult case to the user instead of ignoring it. AltsCodexAuth 는 앱당 하나만 만들어 재사용합니다. 결과 런처는 onCreate 에서 매번 등록해야 Activity 가 다시 만들어지거나 프로세스가 재시작된 뒤에도 결과를 받습니다. AuthResult 는 경우마다 사용자에게 알리고, 조용히 무시하지 마세요.

import com.altscodex.sdk.*
// Android and AndroidX imports omitted.

class App : Application() { // register as android:name in AndroidManifest.xml
    val auth by lazy {
        AltsCodexAuth(this, AltsCodexConfig(clientId = "YOUR_CLIENT_ID", redirectUri = "com.example.app:/callback"))
    }
}

class MainActivity : AppCompatActivity() {
    private val auth get() = (application as App).auth
    private lateinit var launcher: ActivityResultLauncher<Intent>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            lifecycleScope.launch {
                when (val r = auth.handleResult(it.data)) {
                    is AuthResult.Success -> showSignedIn(r.session.slot.nickname)
                    AuthResult.Cancelled -> showMessage("Sign-in cancelled")
                    AuthResult.Denied -> showMessage("Access was declined")
                    AuthResult.Expired -> showMessage("Sign-in took too long. Please try again.")
                    // r.stage is safe to log. StaleCallback: an older sign-in returned, the newer one is still pending.
                    is AuthResult.Failed -> if (r.stage != Stage.StaleCallback) showMessage("Sign-in failed (${r.stage})")
                }
            }
        }
    }

    fun signIn() = lifecycleScope.launch {
        try { launcher.launch(auth.loginIntent()) } catch (e: AltsCodexException) { showMessage("Could not start sign-in (${e.stage})") }
    }

    suspend fun callApi() {
        val session = try { auth.session() } catch (e: AltsCodexException) {
            showMessage("Network error. Please try again."); return // the session is kept, retry later
        }
        if (session == null) { signIn(); return } // signed out or refresh rejected: sign in again
        myApi.get(bearerToken = session.accessToken) // refreshed first when it expires within a minute
    }

    fun signOut() = lifecycleScope.launch {
        val result = auth.signOut() // removes the local session first, even offline
        if (!result.serverRevoked) showMessage("Signed out on this device. Server revocation was not confirmed.")
    }
}

API API

APIDescription
AltsCodexAuth(context, config) Creates the client. It uses the built-in Keystore-backed store unless config.store is set. Create one per app and reuse it.클라이언트를 만듭니다. config.store 를 지정하지 않으면 내장 Keystore 저장소를 씁니다. 앱당 하나만 만들어 재사용합니다.
suspend fun loginIntent(): Intent Loads discovery, stores the pending sign-in and returns the Custom Tabs Intent. A previous pending sign-in is replaced. Throws AltsCodexException when discovery or storage fails.디스커버리를 읽고 대기 중인 로그인을 저장한 뒤 Custom Tabs Intent 를 돌려줍니다. 이전 대기 로그인은 교체됩니다. 디스커버리나 저장이 실패하면 AltsCodexException 을 던집니다.
suspend fun handleResult(data: Intent?): AuthResult Validates the callback, exchanges the code, then validates and stores the session. It never throws.콜백을 검증하고 코드를 교환한 뒤 세션을 검증·저장합니다. 예외를 던지지 않습니다.
suspend fun session(): Session? Returns a valid session, refreshed first when the access token expires within a minute. Concurrent calls share one refresh. null means signed out or the refresh token was rejected, so sign in again. Throws AltsCodexException with Stage.Refresh on a network or server error, and the session is kept so you can retry. Throws Stage.Storage when storage is unreadable.유효한 세션을 돌려주며, access token 만료가 1분 안이면 먼저 갱신합니다. 동시에 호출해도 갱신은 한 번만 실행됩니다. null 이면 로그아웃 상태이거나 refresh token 이 거절된 것이므로 다시 로그인합니다. 네트워크·서버 오류는 Stage.Refresh 의 AltsCodexException 으로 알리며, 세션은 유지되므로 나중에 다시 시도합니다. 저장소를 읽지 못하면 Stage.Storage 로 던집니다.
suspend fun signOut(): SignOutResult Removes the local session and any pending sign-in first, then revokes the refresh token on the server. The browser's AltsCodex session is not affected.기기의 세션과 대기 중인 로그인을 먼저 지우고, 그다음 서버에서 refresh token 을 폐기합니다. 브라우저의 AltsCodex 세션에는 영향이 없습니다.
val currentSession: Session? The stored session without a network call. It may be expired, so call session() before an API call.네트워크 호출 없이 저장된 세션을 돌려줍니다. 만료됐을 수 있으므로 API 호출 전에는 session() 을 쓰세요.
AltsCodexConfig.clientId Required. The client_id of the public client from section 1.필수. 1절에서 등록한 공개 클라이언트의 client_id.
AltsCodexConfig.redirectUri Required. The redirect URI exactly as registered, for example com.example.app:/callback. Its scheme must match appAuthRedirectScheme.필수. 등록한 값 그대로의 redirect URI(예: com.example.app:/callback). 스킴은 appAuthRedirectScheme 과 같아야 합니다.
AltsCodexConfig.pendingTtlMs How long a started sign-in may wait for its callback. Default 10 minutes. This is an app policy, separate from the 60-second lifetime of the authorization code.시작한 로그인이 콜백을 기다릴 수 있는 시간. 기본 10분. 인가 코드의 60초 유효시간과는 별개인 앱 정책입니다.
AltsCodexConfig.store Where the session and the pending sign-in are kept. The default null uses the built-in Keystore store. A replacement SecureStore must encrypt at rest and stay out of backups.세션과 대기 로그인을 보관하는 곳. 기본값 null 은 내장 Keystore 저장소를 씁니다. 직접 만든 SecureStore 는 저장 시 암호화하고 백업에서 제외해야 합니다.
AltsCodexConfig.logger Optional diagnostics hook. It receives a Stage and fixed values such as presence, type and result. The default null logs nothing.선택 진단 훅. Stage 와 존재 여부·타입·결과 같은 고정 값만 받습니다. 기본값 null 이면 아무것도 기록하지 않습니다.
AuthResult.Success(session) Sign-in finished and the session was stored.로그인이 끝났고 세션이 저장됐습니다.
AuthResult.Cancelled The user closed the browser or cancelled the flow.사용자가 브라우저를 닫았거나 로그인을 취소했습니다.
AuthResult.Denied The user declined consent (error=access_denied).사용자가 동의를 거절했습니다(error=access_denied).
AuthResult.Expired The pending sign-in was older than pendingTtlMs. Start a new sign-in.대기 로그인이 pendingTtlMs 보다 오래됐습니다. 새로 로그인합니다.
AuthResult.Failed(stage) Sign-in failed at stage. Stage is safe to log and never contains a token, code or user value. StaleCallback means an older sign-in returned while a newer one is pending. CodeExpiredOrUsed means the code expired or was already used, so sign in again.stage 단계에서 로그인이 실패했습니다. Stage 는 로그에 남겨도 안전하며 토큰·코드·사용자 값을 담지 않습니다. StaleCallback 은 더 새로운 로그인이 대기 중일 때 이전 로그인이 돌아온 경우이고, CodeExpiredOrUsed 는 코드가 만료됐거나 이미 쓰인 경우이므로 다시 로그인합니다.
Session(accessToken, refreshToken, expiresAt, slot) expiresAt is the access token expiry in epoch milliseconds. toString() redacts both tokens.expiresAt 은 access token 만료 시각(epoch 밀리초)입니다. toString() 은 두 토큰을 가립니다.
Slot(id, nickname, contentAddress, transferCount) id is always a String normalized to decimal, so JSON 42 and "42" give the same id. nickname is slot.token_nickname, contentAddress is slot.content_address or null, and transferCount is slot.tr_cnt or 0.id 는 항상 10진수로 정규화된 String 이라 JSON 42 와 "42" 는 같은 id 입니다. nickname 은 slot.token_nickname, contentAddress 는 slot.content_address 또는 null, transferCount 는 slot.tr_cnt 또는 0 입니다.
SignOutResult(localCleared, serverRevoked) localCleared is true when the session was removed from the device. serverRevoked is false when the server did not confirm revocation, and true when there was no session to revoke.localCleared 는 기기에서 세션을 지웠으면 true 입니다. serverRevoked 는 서버 폐기를 확인하지 못했으면 false, 폐기할 세션이 없었으면 true 입니다.

What the SDK handles for you SDK 가 대신 처리하는 것

The SDK creates state and the PKCE S256 verifier and challenge, and keeps a single pending sign-in that is consumed once, so the callback is matched even after a process restart. It never exchanges the same code twice, because the authorization code is valid for 60 seconds and once only. It checks the types of the slot fields before trusting them. The session is encrypted with an AES-GCM key held in the Android Keystore and stored in a file excluded from backup, so no extra manifest backup rules are needed. Only one refresh runs at a time, the rotated refresh token is stored together with the new access token, and a refresh response that arrives after sign-out is discarded. Sign-out revokes the refresh token on the server. The logger receives only stages and presence, type and result values. Tokens, codes, state and PKCE verifiers are never passed to it. SDK 는 state 와 PKCE S256 verifier·challenge 를 만들고, 대기 중인 로그인을 하나만 두어 한 번만 소비하므로 프로세스가 재시작돼도 콜백을 대조합니다. 인가 코드는 60초 동안 한 번만 유효하므로 같은 코드를 다시 교환하지 않습니다. slot 필드는 타입을 확인한 뒤에만 씁니다. 세션은 Android Keystore 의 AES-GCM 키로 암호화해 백업 제외 파일에 저장하므로 매니페스트에 백업 규칙을 따로 넣지 않아도 됩니다. 갱신은 한 번에 하나만 실행하고, 회전된 refresh token 을 새 access token 과 함께 저장하며, 로그아웃 뒤 늦게 도착한 갱신 응답은 버립니다. 로그아웃 시 서버에서 refresh token 을 폐기합니다. 로거에는 단계와 존재 여부·타입·결과 값만 전달되고, 토큰·코드·state·PKCE verifier 는 절대 전달되지 않습니다.

If you need to control each step yourself, for example to use a different AppAuth version, follow B below. 다른 AppAuth 버전을 쓰는 등 각 단계를 직접 제어해야 하면 아래 B 를 따르세요.

B. Implement directly with AppAuth B. AppAuth 로 직접 구현

1. Registration values 1. 등록 값

  • Use the client_id of the public client you registered in section 1. It is the only credential the app contains. 1절에서 등록한 공개 클라이언트의 client_id 를 씁니다. 앱에 들어가는 자격 정보는 이것 하나뿐입니다.
  • The redirect URI in the app must match the registered value character for character. com.example.app:/callback (one slash, no host) and com.example.app://callback (host callback) are different URIs. 앱의 redirect URI 는 등록한 값과 한 글자도 다르지 않게 일치해야 합니다. com.example.app:/callback(슬래시 하나, host 없음)과 com.example.app://callback(host 가 callback)은 서로 다른 URI 입니다.
  • Even if the developer center shows a client_secret for your application, do not put it in the app. 개발자센터에 client_secret 이 표시되더라도 앱에 넣지 않습니다.
  • To change the redirect URI later, open My Applications → Edit. redirect URI 를 바꾸려면 My Applications → Edit 에서 수정합니다.

2. Gradle setup 2. Gradle 설정

appAuthRedirectScheme is the scheme part only (com.example.app), not the full redirect URI. AppAuth registers a redirect receiver for that scheme in the merged manifest. appAuthRedirectScheme 에는 redirect URI 전체가 아니라 스킴 부분(com.example.app)만 넣습니다. AppAuth 가 병합된 매니페스트에 이 스킴의 리다이렉트 수신 Activity 를 등록합니다.

// app/build.gradle.kts — flutter_appauth and AppAuth-Android both read this placeholder
android {
    defaultConfig {
        manifestPlaceholders["appAuthRedirectScheme"] = "com.example.app"
    }
}

dependencies {
    implementation("net.openid:appauth:0.11.1") // native Android only
}

Exclude the token store and the pending login request from backup. Set android:allowBackup="false", or exclude those files with android:dataExtractionRules (Android 12+) and android:fullBackupContent. 토큰 저장소와 대기 중인 로그인 요청은 백업에서 제외하세요. android:allowBackup="false" 로 끄거나, android:dataExtractionRules(Android 12 이상)와 android:fullBackupContent 로 해당 파일을 제외합니다.

3. Login request 3. 로그인 요청

  • Load the configuration with discovery (AuthorizationServiceConfiguration.fetchFromIssuer, issuer https://api.altscodex.com), then build an AuthorizationRequest with ResponseTypeValues.CODE. 디스커버리(AuthorizationServiceConfiguration.fetchFromIssuer, issuer https://api.altscodex.com)로 설정을 읽고, ResponseTypeValues.CODE 로 AuthorizationRequest 를 만듭니다.
  • AppAuth generates state, the PKCE code_verifier and the S256 code_challenge automatically. Do not replace them with your own values. AppAuth 가 state, PKCE code_verifier, S256 code_challenge 를 자동으로 만듭니다. 직접 만든 값으로 바꾸지 마세요.
  • Do not request the openid scope or any other scope. AltsCodex issues no id_token. openid scope 나 다른 scope 를 요청하지 마세요. AltsCodex 는 id_token 을 발급하지 않습니다.
  • Open the login page with getAuthorizationRequestIntent, which uses Custom Tabs or the default browser. Never use a WebView (see section 8). 로그인 화면은 Custom Tabs 또는 기본 브라우저를 쓰는 getAuthorizationRequestIntent 로 엽니다. WebView 는 쓰지 마세요(8절 참고).
  • Before launching, save the pending request (AuthorizationRequest.jsonSerializeString()) and its start time in secure storage, so the app can still match the callback after its process is killed. 브라우저를 열기 전에 대기 요청(AuthorizationRequest.jsonSerializeString())과 시작 시각을 보안 저장소에 저장합니다. 그래야 앱 프로세스가 종료된 뒤에도 콜백을 대조할 수 있습니다.

4. Handling the callback 4. 콜백 처리

Returning from the browser does not mean sign-in succeeded. Handle cancellation and errors explicitly, validate the callback against the pending request, then exchange, validate, and persist the tokens. Apply the same checks after Activity recreation or process restart. 브라우저가 돌아왔다고 로그인이 완료된 것은 아닙니다. 취소 또는 오류를 명시적으로 처리하고, 요청과 콜백을 검증한 뒤 토큰을 교환·검증·저장하세요. Activity 재생성이나 앱 프로세스 종료 후 복귀에도 같은 검증을 적용하세요.
  • Register the ActivityResultLauncher in onCreate every time, so the result still arrives after the Activity is recreated or the process restarts. ActivityResultLauncher 는 onCreate 에서 매번 등록하세요. 그래야 Activity 가 재생성되거나 프로세스가 다시 시작돼도 결과를 받습니다.
  • Never return silently. A null result or AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW means the user cancelled. AuthorizationRequestErrors.ACCESS_DENIED means the user declined. Any other AuthorizationException, including a failed token exchange, is an error. Show each outcome and offer a new login. 조용히 return 하지 마세요. 결과가 null 이거나 AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW 이면 사용자 취소입니다. AuthorizationRequestErrors.ACCESS_DENIED 는 사용자가 동의를 거부한 경우입니다. 토큰 교환 실패를 포함한 그 밖의 AuthorizationException 은 오류입니다. 각 결과를 안내하고 새 로그인을 시작할 수 있게 하세요.
  • Check that the response belongs to the current pending request (same state). If it belongs to an older request, ignore it without clearing the newer pending request. 응답이 현재 대기 요청의 것인지(state 일치) 확인하세요. 이전 요청의 응답이면 무시하되, 더 새로운 대기 요청은 지우지 않습니다.
  • An authorization code is valid for 60 seconds and can be used once. Do not exchange a code you already know is stale, and never retry the exchange with the same code. If the exchange returns invalid_grant, start a new login. 인가 코드는 60초 동안 한 번만 쓸 수 있습니다. 만료된 것이 확실한 코드는 교환하지 말고, 같은 코드로 교환을 재시도하지 마세요. 교환이 invalid_grant 를 반환하면 새 로그인을 시작합니다.
  • Three time limits are separate. The time the user spends on the login page, the app's own timeout for a pending request, and the 60-second code lifetime. The sample uses a 10-minute pending timeout as an example of an app policy. AltsCodex does not impose it. 세 가지 시간 제한은 서로 별개입니다. 사용자가 로그인 화면에 머무는 시간, 앱이 대기 요청에 두는 제한 시간, 코드의 60초 수명입니다. 샘플의 대기 요청 10분 제한은 앱 자체 정책 예시이며 AltsCodex 가 정한 값이 아닙니다.
  • After a process restart, restore the pending request with AuthorizationRequest.jsonDeserialize(...). AppAuth's own authorization Activity also restores its request from saved state. 프로세스가 다시 시작되면 AuthorizationRequest.jsonDeserialize(...) 로 대기 요청을 복원합니다. AppAuth 의 인가 Activity 도 저장된 상태에서 자기 요청을 복원합니다.
  • Optional hardening. Validate the URI that actually arrived, not the stored request.redirectUri. Receive the redirect in your own exported Activity, reject it unless the scheme and path match, there is no host or fragment, no query key repeats, state is present, and exactly one of code or error is present. Then forward it to net.openid.appauth.RedirectUriReceiverActivity and make that Activity non-exported in your manifest. 선택 보강. 저장된 request.redirectUri 가 아니라 실제로 들어온 URI 를 검증하세요. 직접 만든 exported Activity 로 리다이렉트를 받고, 스킴·경로가 일치하고 host·fragment 가 없으며 query 키가 중복되지 않고 state 가 있고 code 와 error 중 정확히 하나만 있을 때만 통과시킵니다. 그 뒤 net.openid.appauth.RedirectUriReceiverActivity 로 넘기고, 매니페스트에서 그 Activity 를 non-exported 로 바꿉니다.
An authorization code is valid for 60 seconds after issuance and can be used only once. An application's timeout for a pending sign-in request is a separate policy. If the user cancels or the pending request expires, explain the outcome and start a new request. A callback for an older request must not cancel a newer request. 인증 코드는 발급 후 60초 동안 한 번만 사용할 수 있습니다. 앱이 로그인 대기 요청에 적용하는 제한 시간은 별도 정책입니다. 사용자가 취소하거나 앱의 대기 요청이 만료되면 이유를 안내하고 새 로그인 요청을 시작하세요. 이전 요청의 콜백으로 새 요청을 취소하지 마세요.

5. Token response and slot 5. 토큰 응답과 slot

The first code exchange and every refresh return the same shape. slot is always present in both. 최초 코드 교환과 모든 리프레시는 같은 형태로 응답합니다. 두 경우 모두 slot 이 항상 포함됩니다.

FieldTypeDescription
access_token string App access token.앱 access token.
token_type string "Bearer"
expires_in number Access token lifetime in seconds. 86400 by default, and the server can change it. Read the value instead of hard-coding 86400.access token 수명(초). 기본 86400 이며 서버 설정으로 바뀔 수 있습니다. 86400 을 고정하지 말고 응답 값을 쓰세요.
refresh_token string Rotated on every refresh.리프레시할 때마다 회전됩니다.
slot.id number Slot identifier, sent as a JSON integer.슬롯 식별자. JSON 정수로 전송됩니다.
slot.token_nickname string Slot nickname, up to 32 characters.슬롯 닉네임. 최대 32자.
slot.content_address string | null Slot payment wallet address. Can be null.슬롯 결제 지갑 주소. null 일 수 있습니다.
slot.tr_cnt number Slot transfer count.슬롯 이전(transfer) 횟수.
slot.lease object { state, expires_at }. state is none or borrowed. When it is borrowed, this account (Slot) is lent to another user for a fixed period and user_uuid is the account borrower (lessee). After expires_at (ISO 8601 UTC, null when none), app sign-in is rejected with SLOT_LEASE_EXPIRED and the account returns to the account owner (lessor) automatically. Apps keep working even if they ignore this field. See the Account (Slot) Lease guide.{ state, expires_at } 형식입니다. state 는 none 또는 borrowed 입니다. borrowed 면 이 계정(Slot)은 다른 사용자에게 기간 대여된 상태이며 user_uuid 는 계정 대여자(lessee)입니다. expires_at(ISO 8601 UTC, none 이면 null) 이후 앱 로그인은 SLOT_LEASE_EXPIRED 로 거부되고 계정은 계정 소유자(lessor)에게 자동 반환됩니다. 앱은 이 필드를 무시해도 동작합니다. 자세한 내용은 계정(Slot) 대여 가이드를 보세요.
On Android, additionalParameters["slot"] is a JSON string. Validate the types of its fields before using them. Do not coerce arbitrary JSON values into an ID string. The client compatibility example below accepts strings and losslessly represented non-negative integers; it does not define the provider's official ID range or guarantee numeric precision in every language. Android에서 additionalParameters["slot"]은 JSON 문자열입니다. 내부 필드의 타입을 확인한 뒤 사용하세요. ID를 임의의 JSON 값에서 문자열로 강제 변환하지 마세요. 아래 클라이언트 호환성 예제는 문자열과 손실 없이 표현된 비음수 정수만 처리하며, 공급자의 공식 ID 범위나 모든 언어의 숫자 정밀도를 보장하지 않습니다.
  • Parse the string once more with JSONObject. org.json returns integers as Int or Long and larger numbers as Double. The helper slotId(value: Any?): String? in the sample accepts a non-blank string or a non-negative Int/Long and returns its decimal string, so 42 and "42" become the same ID. It returns null for Double, Boolean, objects and null, and the caller treats null as a failed login. The 512-character limit on string IDs is an example of an app policy. 문자열을 JSONObject 로 한 번 더 파싱합니다. org.json 은 정수를 Int 또는 Long 으로, 그보다 큰 수는 Double 로 돌려줍니다. 샘플의 도우미 slotId(value: Any?): String? 는 공백이 아닌 문자열과 음수가 아닌 Int·Long 만 받아 십진 문자열로 바꾸므로 42 와 "42" 는 같은 ID 가 됩니다. Double·Boolean·객체·null 은 null 을 반환하고, 호출자는 이를 로그인 실패로 처리합니다. 문자열 ID 512자 제한은 앱 자체 정책 예시입니다.
  • Do not use getString() or toString() as validation. They turn objects, booleans, null and rounded floating-point values into strings that look valid. getString() 이나 toString() 을 검증 대신 쓰지 마세요. 객체·Boolean·null·반올림된 부동소수 값까지 정상처럼 보이는 문자열로 바꿉니다.
  • Check token_nickname separately with as? String. Do not let the ID compatibility rule hide a nickname type error. token_nickname 은 as? String 으로 따로 검사합니다. ID 호환 처리로 닉네임 타입 오류를 덮지 마세요.
  • Before showing the user as signed in, confirm that the access and refresh tokens are present, token_type is Bearer, the expiry time is in the future, slot parses with valid field types, and the session was written to storage. 로그인 완료로 표시하기 전에 access·refresh 토큰 존재, token_type 이 Bearer 인지, 만료 시각이 미래인지, slot 이 파싱되고 필드 타입이 맞는지, 세션이 저장소에 기록됐는지 확인합니다.

6. Persistent login and refresh rotation 6. 로그인 유지와 리프레시 회전

Securely persist the new access token and refresh token together. Serialize refresh operations and prevent a response arriving after logout from restoring the session. Distinguish a network failure from a refresh rejected by the server. If a response or local write is lost during token rotation, the user may need to sign in again. 갱신 응답의 새 access token과 refresh token은 함께 안전하게 저장하세요. 갱신을 직렬화하고, 로그아웃 이후 도착한 응답으로 세션을 되살리지 마세요. 네트워크 실패와 서버가 거절한 갱신을 구분하세요. 토큰 회전 중 응답이나 저장이 유실되면 다시 로그인해야 할 수 있습니다.
  • Store the access token, refresh token, absolute expiry time and slot ID as one record in storage encrypted with an Android Keystore key (for example an AES-GCM key from the Keystore and an atomically written file). Replace the whole record in a single write. access token, refresh token, 만료 시각(절대 시각), slot ID 를 하나의 레코드로 Android Keystore 키로 암호화한 저장소(예: Keystore 의 AES-GCM 키 + 원자적으로 쓰는 파일)에 저장합니다. 레코드 전체를 한 번의 쓰기로 교체하세요.
  • Run at most one refresh at a time. Other callers wait and reuse its result. If two refreshes send the same refresh token at once, only one succeeds and the other gets invalid_grant because the token was already rotated. 리프레시는 한 번에 하나만 실행하고, 다른 호출은 기다렸다가 그 결과를 씁니다. 같은 refresh token 으로 두 리프레시가 동시에 나가면 하나만 성공하고 나머지는 이미 회전된 토큰이라 invalid_grant 를 받습니다.
  • A network failure keeps the stored session so the app can retry later. invalid_grant means the refresh token expired, was revoked or was already rotated, so clear the session and ask the user to sign in again. 네트워크 실패면 저장된 세션을 유지하고 나중에 재시도합니다. invalid_grant 는 refresh token 이 만료·폐기됐거나 이미 회전됐다는 뜻이므로 세션을 지우고 다시 로그인하게 합니다.
  • If the user signs out while a refresh is in flight, drop the late response. Check that the slot.id in the refresh response matches the stored one. 리프레시 도중 사용자가 로그아웃했다면 늦게 도착한 응답은 버립니다. 리프레시 응답의 slot.id 가 저장된 값과 같은지 확인하세요.
  • Keep "a stored session the app can retry" separate from "features the user may use". Whether your app requires sign-in before any feature works is your product decision, not an AltsCodex requirement. "재시도할 수 있는 저장 세션"과 "사용자에게 허용할 기능"은 따로 관리하세요. 모든 기능 전에 로그인을 요구할지는 앱의 제품 정책이며 AltsCodex 의 요구사항이 아닙니다.

7. Sign out 7. 로그아웃

  1. Delete the local session first. The UI switches to signed-out immediately, without waiting for the network. 먼저 로컬 세션을 지웁니다. 네트워크 응답을 기다리지 않고 UI 를 바로 로그아웃 상태로 바꿉니다.
  2. Then call the revocation endpoint (section 6) with token, token_type_hint and client_id, form-urlencoded. If the request fails, tell the user they are signed out on this device but the server revocation was not confirmed. 그다음 revoke 엔드포인트(6절)에 token, token_type_hint, client_id 를 form-urlencoded 로 보냅니다. 요청이 실패하면 이 기기에서는 로그아웃됐지만 서버 폐기는 확인되지 않았다고 안내하세요.
  3. The AltsCodex session in the browser is separate and stays signed in. Seeing the "Continue" screen on the next login is expected and is not a failure or a sign that the app's tokens came back. 브라우저의 AltsCodex 세션은 별개이며 로그인 상태로 남습니다. 다음 로그인에서 "Continue" 화면이 뜨는 것은 정상이며, 실패나 앱 토큰이 되살아났다는 뜻이 아닙니다.

8. Diagnostics and FAQ 8. 진단과 FAQ

Symptom증상Check, in order확인 순서
The browser shows ERR_OAUTH브라우저에 ERR_OAUTH 가 보임 Do not assume it is the same problem as a token parsing failure in the app. Start with the error code and the step where it appeared.앱의 토큰 파싱 실패와 같은 문제라고 단정하지 마세요. 오류 코드와 발생 단계부터 확인합니다.
The browser stays open after ContinueContinue 후 브라우저에 머묾 Redirect delivery to the app, the return to AppAuth, whether a pending request exists and whether it passed the app's timeout. Do not start by clearing cookies.앱으로 리다이렉트 전달, AppAuth 복귀, 대기 요청 존재 여부와 앱 제한 시간 초과 여부를 봅니다. 쿠키 삭제부터 시도하지 마세요.
It fails after returning to the app앱으로 돌아온 뒤 실패 Split it into URI and state validation, AppAuth response or exception, token exchange, field parsing, then secure storage.URI·state 검증, AppAuth 응답·예외, 토큰 교환, 필드 파싱, 보안 저장 순서로 나눠 봅니다.
A token response exists but login fails토큰 응답은 있는데 실패 Access and refresh token present, token type, expiry present and in the future, slot is JSON, and the field types.access·refresh 존재, token type, 만료 시각 존재·미래 여부, slot 이 JSON 인지, 필드 타입을 봅니다.
The login screen appears after restarting the app앱 재실행 후 로그인 화면 Whether the write succeeded, the stored client binding, decryption errors, and the expiry or refresh result. Never print tokens to check.저장 성공 여부, 저장된 client 바인딩, 복호화 실패, 만료·리프레시 결과를 봅니다. 토큰을 출력해서 확인하지 마세요.
Retrying does not work재시도가 안 됨 The old pending request was cleared, a new state was created, and a callback for the old request did not remove the new one.이전 대기 요청 정리, 새 state 생성, 이전 콜백이 새 요청을 지우지 않았는지 봅니다.

Log only the stage, whether a value is present, its type and the validation result. A safe log line looks like this. 로그에는 단계, 값의 존재 여부, 타입, 검증 결과만 남깁니다. 안전한 로그는 다음과 같습니다.

stage=TokenValidation accessPresent=true idType=number nicknameType=string
⚠️ Never log or paste into a ticket ⚠️ 로그·이슈에 절대 남기지 말 것
  • The full redirect URI or query, authorization code, state, PKCE verifier, access and refresh tokens, client_secret. 전체 redirect URI·query, 인가 코드, state, PKCE verifier, access·refresh 토큰, client_secret.
  • Raw request or response JSON, the Authorization header, cookies, the user's ID, nickname or email, and free-form error_description. 원문 요청·응답 JSON, Authorization 헤더, 쿠키, 사용자 ID·닉네임·이메일, 자유 형식 error_description.
  • Full exception messages or stack traces that may contain any of the above. Log a classified error instead, and limit OAuth error strings to a fixed allowlist. 위 값이 담길 수 있는 예외 메시지 전문이나 스택. 대신 분류된 오류를 남기고, OAuth 오류 문자열도 고정된 허용 목록으로 제한하세요.

9. Kotlin sample 9. Kotlin 샘플

The sample covers steps 1 to 7. It needs AppAuth-Android 0.11.1 and kotlinx-coroutines. Connect SecureStore to Keystore-backed storage and map AuthResult to your own UI and error types. 샘플은 1~7단계를 모두 담고 있으며 AppAuth-Android 0.11.1 과 kotlinx-coroutines 가 필요합니다. SecureStore 는 Keystore 기반 저장소에 연결하고, AuthResult 는 앱의 UI·오류 타입에 맞게 연결하세요.

// AltsCodex sign-in with AppAuth-Android 0.11.1 and kotlinx-coroutines.
// Connect SecureStore to Keystore-backed storage and AuthResult to your UI.
import android.content.*
import android.net.Uri
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
import net.openid.appauth.*
import org.json.*
import java.net.*
import kotlin.coroutines.*

// 1. Registration values. Public client: client_id only, never a client_secret.
const val CLIENT_ID = "YOUR_CLIENT_ID"
val REDIRECT_URI: Uri = Uri.parse("com.example.app:/callback") // exactly as registered
val ISSUER: Uri = Uri.parse("https://api.altscodex.com")
const val PENDING_TTL_MS = 10 * 60_000L // app policy example, not a provider limit

data class Session(val accessToken: String, val refreshToken: String,
                   val expiresAt: Long, val slotId: String, val nickname: String)

sealed class AuthResult {
    data class Success(val session: Session) : AuthResult()
    object Cancelled : AuthResult()                     // user closed the browser
    object Denied : AuthResult()                        // error=access_denied
    object Expired : AuthResult()                       // pending request too old
    data class Failed(val stage: String) : AuthResult() // a stage name, never a token
}

// Storage encrypted with an Android Keystore key (AES-GCM key + atomic file), excluded from backup.
// Each put is one commit. put(key, null) removes the key.
interface SecureStore { fun get(key: String): String?; fun put(key: String, value: String?) }

// 5. Accept a non-blank string or a non-negative Int/Long without loss. Reject Double,
// Boolean, objects and null instead of coercing them with toString().
// The 512-character limit is an app policy example.
fun slotId(value: Any?): String? = when (value) {
    is String -> value.takeIf { it.isNotBlank() && it.length <= 512 }
    is Int -> value.takeIf { it >= 0 }?.toString()
    is Long -> value.takeIf { it >= 0 }?.toString()
    else -> null
}

// 4. Optional hardening: run this in your own redirect Activity before forwarding the
// URI to AppAuth. AppAuth still checks that state matches the pending request.
fun isWellFormedCallback(uri: Uri): Boolean {
    if (uri.scheme != REDIRECT_URI.scheme || uri.encodedAuthority != null ||
        uri.path != REDIRECT_URI.path || uri.encodedFragment != null) return false
    val names = uri.queryParameterNames
    if (uri.encodedQuery?.split('&')?.size != names.size) return false // duplicate keys
    val value = when {
        "code" in names && "error" !in names -> uri.getQueryParameter("code")
        "error" in names && "code" !in names -> uri.getQueryParameter("error")
        else -> null                                                // both or neither
    }
    return !uri.getQueryParameter("state").isNullOrBlank() && !value.isNullOrBlank()
}

class AltsCodexAuth(context: Context, private val store: SecureStore) {
    private val service = AuthorizationService(context)
    private val refreshLock = Mutex()
    private var config: AuthorizationServiceConfiguration? = null
    @Volatile private var generation = 0 // bumped on sign-out so late responses are dropped

    // 3. Login request. AppAuth creates state and the PKCE S256 verifier and challenge.
    suspend fun createLoginIntent(): Intent {
        val request = AuthorizationRequest.Builder(
            discover(), CLIENT_ID, ResponseTypeValues.CODE, REDIRECT_URI
        ).build() // no openid scope: AltsCodex issues no id_token
        // Save before launching, so a restarted process can still match the callback.
        store.put("pending", JSONObject().put("request", request.jsonSerializeString())
            .put("startedAt", System.currentTimeMillis()).toString())
        return service.getAuthorizationRequestIntent(request) // Custom Tabs, never a WebView
    }

    // 4. Callback. Call from an ActivityResultLauncher registered in onCreate.
    suspend fun handleResult(data: Intent?): AuthResult {
        val pending = store.get("pending")?.let { JSONObject(it) }
            ?: return AuthResult.Failed("NoPendingRequest")
        val request = AuthorizationRequest.jsonDeserialize(pending.getString("request"))
        if (data == null) return clearPending(AuthResult.Cancelled)
        AuthorizationException.fromIntent(data)?.let { ex ->
            return clearPending(when (ex) {
                AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW -> AuthResult.Cancelled
                AuthorizationException.AuthorizationRequestErrors.ACCESS_DENIED -> AuthResult.Denied
                else -> AuthResult.Failed("Authorization")
            })
        }
        val response = AuthorizationResponse.fromIntent(data)
            ?: return clearPending(AuthResult.Failed("NoAuthorizationResponse"))
        // A callback for an older request must not cancel the newer pending request.
        if (response.request.state != request.state) return AuthResult.Failed("StaleCallback")
        if (System.currentTimeMillis() - pending.getLong("startedAt") > PENDING_TTL_MS) {
            return clearPending(AuthResult.Expired) // do not exchange a code we know is stale
        }
        store.put("pending", null) // the code is single-use, so consume the pending request once
        return try {
            val session = parseTokens(tokenRequest(response.createTokenExchangeRequest()))
                ?: return AuthResult.Failed("TokenValidation")
            store.put("session", session.toJson()) // signed in only after this write succeeds
            AuthResult.Success(session)
        } catch (e: AuthorizationException) {
            // invalid_grant: the code expired (60 s) or was used. Start a new login.
            AuthResult.Failed(if (e == AuthorizationException.TokenRequestErrors.INVALID_GRANT)
                "CodeExpiredOrUsed" else "TokenExchange")
        }
    }

    // 6. Returns a valid session, or null when the user must sign in again.
    // Throws AuthorizationException on network errors: keep the session and retry later.
    suspend fun freshSession(): Session? = refreshLock.withLock { // one refresh at a time
        val current = store.get("session")?.let { sessionFromJson(it) } ?: return null
        if (current.expiresAt - 60_000 > System.currentTimeMillis()) return current
        val startedIn = generation
        val tokens = try {
            tokenRequest(TokenRequest.Builder(discover(), CLIENT_ID)
                .setGrantType(GrantTypeValues.REFRESH_TOKEN)
                .setRefreshToken(current.refreshToken).build())
        } catch (e: AuthorizationException) {
            if (e != AuthorizationException.TokenRequestErrors.INVALID_GRANT) throw e
            store.put("session", null) // expired, revoked, or already rotated elsewhere
            return null
        }
        if (startedIn != generation) return null // signed out while refreshing: drop it
        val next = parseTokens(tokens)?.takeIf { it.slotId == current.slotId }
        store.put("session", next?.toJson()) // new access and refresh tokens together
        next
    }

    // 7. Sign out locally first, then revoke. Returns false when the server revocation
    // was not confirmed. The browser's AltsCodex session is not affected.
    suspend fun signOut(): Boolean {
        val session = store.get("session")?.let { sessionFromJson(it) }
        generation++
        store.put("session", null); store.put("pending", null)
        if (session == null) return true
        return withContext(Dispatchers.IO) { runCatching { revoke(session.refreshToken) }.isSuccess }
    }

    // 5. Validate before trusting. On Android, additionalParameters["slot"] is a JSON string.
    private fun parseTokens(t: TokenResponse): Session? {
        val access = t.accessToken ?: return null
        val refresh = t.refreshToken ?: return null
        if (!"Bearer".equals(t.tokenType, ignoreCase = true)) return null
        val expiresAt = t.accessTokenExpirationTime
            ?.takeIf { it > System.currentTimeMillis() } ?: return null
        val slot = try { JSONObject(t.additionalParameters["slot"] ?: return null) }
            catch (e: JSONException) { return null }
        val id = slotId(slot.opt("id")) ?: return null
        val nickname = slot.opt("token_nickname") as? String ?: return null
        return Session(access, refresh, expiresAt, id, nickname)
    }

    private fun clearPending(result: AuthResult): AuthResult =
        result.also { store.put("pending", null) }

    private suspend fun discover(): AuthorizationServiceConfiguration = config
        ?: suspendCancellableCoroutine { cont ->
            AuthorizationServiceConfiguration.fetchFromIssuer(ISSUER) { cfg, ex ->
                if (cfg != null) cont.resume(cfg)
                else cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR)
            }
        }.also { config = it }

    private suspend fun tokenRequest(request: TokenRequest): TokenResponse =
        suspendCancellableCoroutine { cont ->
            service.performTokenRequest(request) { response, ex ->
                if (response != null) cont.resume(response)
                else cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.SERVER_ERROR)
            }
        }

    private fun revoke(token: String) {
        val body = listOf("token" to token, "token_type_hint" to "refresh_token", "client_id" to CLIENT_ID)
            .joinToString("&") { (k, v) -> k + "=" + URLEncoder.encode(v, "UTF-8") }
        val conn = URL("https://api.altscodex.com/v1/oauth2/revoke").openConnection() as HttpURLConnection
        try {
            conn.requestMethod = "POST"; conn.doOutput = true
            conn.connectTimeout = 10_000; conn.readTimeout = 10_000
            conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
            conn.outputStream.use { it.write(body.toByteArray()) }
            check(conn.responseCode == 200)
        } finally { conn.disconnect() }
    }

    private fun Session.toJson() = JSONObject().put("access", accessToken)
        .put("refresh", refreshToken).put("expiresAt", expiresAt)
        .put("slotId", slotId).put("nickname", nickname).toString()

    private fun sessionFromJson(s: String) = JSONObject(s).run {
        Session(getString("access"), getString("refresh"), getLong("expiresAt"),
            getString("slotId"), getString("nickname"))
    }
}

// Usage in an Activity:
// val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
//     lifecycleScope.launch { render(auth.handleResult(it.data)) } }
// lifecycleScope.launch { launcher.launch(auth.createLoginIntent()) }

Redirect scheme setup 리다이렉트 스킴 설정

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.example.app</string>
    </array>
  </dict>
</array>

Login, refresh, sign out 로그인·리프레시·로그아웃

import AppAuth

let clientId = "YOUR_CLIENT_ID"
let redirectURL = URL(string: "com.example.app:/callback")!
let issuer = URL(string: "https://api.altscodex.com")!

// Discovery: reads https://api.altscodex.com/.well-known/openid-configuration
OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { config, error in
    guard let config = config else { return }

    // state, code_verifier and code_challenge (S256) are generated automatically.
    // Do not request the openid scope. No id_token is issued.
    let request = OIDAuthorizationRequest(
        configuration: config,
        clientId: clientId,
        scopes: nil,
        redirectURL: redirectURL,
        responseType: OIDResponseTypeCode,
        additionalParameters: nil)

    // Keep the session alive (e.g. in AppDelegate) until the redirect returns.
    // On iOS 12+ AppAuth uses ASWebAuthenticationSession.
    appDelegate.currentAuthorizationFlow = OIDAuthState.authState(
        byPresenting: request, presenting: viewController
    ) { authState, error in
        guard let authState = authState else { return }
        let accessToken = authState.lastTokenResponse?.accessToken
        let slot = authState.lastTokenResponse?.additionalParameters?["slot"] as? [String: Any]
        // Persist authState in the Keychain.
    }
}

// Refresh: performAction refreshes when needed and stores the rotated refresh token.
authState.performAction { accessToken, _, error in
    // call your API with accessToken
}

// Sign out: POST to the revocation endpoint (form-urlencoded).
var revoke = URLRequest(url: URL(string: "https://api.altscodex.com/v1/oauth2/revoke")!)
revoke.httpMethod = "POST"
revoke.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let refreshToken = authState.refreshToken ?? ""
revoke.httpBody = "token=\(refreshToken)&token_type_hint=refresh_token&client_id=\(clientId)"
    .data(using: .utf8)
URLSession.shared.dataTask(with: revoke).resume()

// Alternative: skip discovery and build the configuration directly.
let manualConfig = OIDServiceConfiguration(
    authorizationEndpoint: URL(string: "https://www.altscodex.com/oauth/login")!,
    tokenEndpoint: URL(string: "https://api.altscodex.com/v1/oauth2/token")!)

Flutter reads the same redirect scheme settings as native apps. Add the manifestPlaceholders entry from the Android tab (step 2) and the Info.plist entry from the iOS tab. Flutter 도 네이티브 앱과 같은 리다이렉트 스킴 설정을 읽습니다. Android 탭(2단계)의 manifestPlaceholders 항목과 iOS 탭의 Info.plist 항목을 추가하세요.

Login, refresh, sign out 로그인·리프레시·로그아웃

import 'dart:convert';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:http/http.dart' as http;

const clientId = 'YOUR_CLIENT_ID';
const redirectUrl = 'com.example.app:/callback';
const discoveryUrl = 'https://api.altscodex.com/.well-known/openid-configuration';

final appAuth = FlutterAppAuth();

// Login: reads the discovery document, opens the system browser,
// then exchanges the code with PKCE. Do not request the openid scope.
Future<AuthorizationTokenResponse> login() {
  return appAuth.authorizeAndExchangeCode(
    AuthorizationTokenRequest(clientId, redirectUrl,
        discoveryUrl: discoveryUrl),
  );
}

// slot arrives as a JSON string on Android and as a map on iOS.
Map<String, dynamic> readSlot(TokenResponse res) {
  final raw = res.tokenAdditionalParameters?['slot'];
  return raw is String ? jsonDecode(raw) : Map<String, dynamic>.from(raw as Map);
}

// Refresh: always store the NEW refresh token (rotation).
Future<TokenResponse> refresh(String refreshToken) {
  return appAuth.token(TokenRequest(clientId, redirectUrl,
      discoveryUrl: discoveryUrl,
      refreshToken: refreshToken,
      grantType: GrantType.refreshToken));
}

// Sign out: revokes this app's slot tokens only.
Future<void> logout(String refreshToken) async {
  await http.post(
    Uri.parse('https://api.altscodex.com/v1/oauth2/revoke'),
    body: {
      'token': refreshToken,
      'token_type_hint': 'refresh_token',
      'client_id': clientId,
    },
  );
}

// Alternative: skip discovery and pass the endpoints directly.
// Use serviceConfiguration: serviceConfig instead of discoveryUrl above.
const serviceConfig = AuthorizationServiceConfiguration(
  authorizationEndpoint: 'https://www.altscodex.com/oauth/login',
  tokenEndpoint: 'https://api.altscodex.com/v1/oauth2/token',
);

Desktop (Node / Electron) — @altscodex/sdk/native

Install npm install @altscodex/sdk (4.0.0 or later, Node 18+). loginWithLoopback listens on 127.0.0.1 with a random port, opens the system browser, receives one callback, closes the listener, and exchanges the code with PKCE. If your app opens the browser window itself (for example Electron), use the core AltsCodexNative API instead. npm install @altscodex/sdk(4.0.0 이상, Node 18 이상)로 설치합니다. loginWithLoopback 은 127.0.0.1 의 임의 포트로 대기하고, 시스템 브라우저를 열고, 콜백을 한 번 받은 뒤 리스너를 닫고, PKCE 로 code 를 교환합니다. 앱이 브라우저 창을 직접 여는 경우(예: Electron)는 코어 API AltsCodexNative 를 쓰세요.

// npm install @altscodex/sdk
const { loginWithLoopback } = require('@altscodex/sdk/native/node');
const { AltsCodexNative } = require('@altscodex/sdk/native');

// Register http://127.0.0.1/callback (no port) in the developer center.
const { tokens, redirectUri } = await loginWithLoopback({
  clientId: 'YOUR_CLIENT_ID',
  callbackPath: '/callback', // port 0 = random free port
  timeoutMs: 120000,
});
// tokens: { access_token, token_type, expires_in, refresh_token, slot }

// Refresh (always store the NEW refresh_token) and sign out.
const client = new AltsCodexNative({ clientId: 'YOUR_CLIENT_ID', redirectUri });
const next = await client.refresh(tokens.refresh_token);
await client.revoke(next.refresh_token, { tokenTypeHint: 'refresh_token' });
// 1. Start your own listener on 127.0.0.1 and build the redirect URI with its port.
const client = new AltsCodexNative({ clientId, redirectUri: `http://127.0.0.1:${port}/callback` });
// 2. Create the request and open auth.url in the system browser (shell.openExternal).
const auth = await client.createAuthorizationRequest();
// 3. On the first request to /callback, verify state, then close the listener.
const { code } = client.parseCallback(req.url, auth.state);
// 4. Exchange the code within 60 seconds.
const tokens = await client.exchangeCode({ code, codeVerifier: auth.codeVerifier });

8 Security requirements 보안 요구사항

⚠️ Do not use a WebView ⚠️ WebView 사용 금지
Embedded WebViews let the app read the user's credentials and cookies, and they do not share the browser's AltsCodex session. Always use the system browser: ASWebAuthenticationSession on iOS and Custom Tabs on Android. AppAuth and flutter_appauth do this by default. 앱 내장 WebView 는 앱이 사용자의 자격증명·쿠키를 읽을 수 있고, 브라우저의 AltsCodex 세션도 공유하지 못합니다. 반드시 시스템 브라우저를 쓰세요. iOS 는 ASWebAuthenticationSession, Android 는 Custom Tabs 입니다. AppAuth 와 flutter_appauth 는 기본으로 이렇게 동작합니다.
⚠️ Custom scheme hijacking ⚠️ 커스텀 스킴 탈취 위험
Any app on the device can register the same custom scheme and intercept the redirect carrying the authorization code. PKCE prevents the stolen code from being exchanged, because the attacker does not have your code_verifier. Still, use a reverse-domain scheme you own (com.example.app), never a generic one like myapp://. 기기의 어떤 앱이든 같은 커스텀 스킴을 등록해 인가 코드가 담긴 리다이렉트를 가로챌 수 있습니다. 공격자는 code_verifier 를 모르므로 PKCE 가 가로챈 code 의 토큰 교환을 막아 줍니다. 그래도 myapp:// 같은 일반적인 이름 대신 소유한 도메인의 역도메인 스킴(com.example.app)을 쓰세요.
ℹ️ Production: prefer HTTPS app links ℹ️ 프로덕션은 HTTPS 앱 링크 권장
For production, register an https:// redirect URI on a domain you own and bind it to your app with Android App Links (assetlinks.json, autoVerify) and iOS Universal Links (apple-app-site-association). The operating system then delivers the redirect only to your verified app. 프로덕션에서는 소유한 도메인의 https:// redirect URI 를 등록하고 Android App Links(assetlinks.json, autoVerify)와 iOS Universal Links(apple-app-site-association)로 앱에 연결하세요. 그러면 운영체제가 검증된 내 앱에만 리다이렉트를 전달합니다.
⚠️ Desktop loopback redirect ⚠️ 데스크톱 루프백 리다이렉트
  • Bind and register only 127.0.0.1 (or [::1]). Never use localhost. The name can resolve elsewhere, and the server rejects it. 127.0.0.1(또는 [::1])에만 바인딩하고 등록하세요. localhost 는 쓰지 마세요. 이름이 다른 주소로 해석될 수 있고 서버도 거부합니다.
  • Accept a single callback, then close the listener immediately. Do not keep the port open. 콜백은 한 번만 받고 즉시 리스너를 닫으세요. 포트를 열어 두지 마세요.
  • Check that the returned state matches before using the code, and exchange it within 60 seconds. Any local process can send a request to your port. code 를 쓰기 전에 돌아온 state 가 일치하는지 확인하고, 60초 안에 교환하세요. 같은 기기의 어떤 프로세스든 그 포트로 요청을 보낼 수 있습니다.
  • Store tokens in the Keychain (iOS) or Keystore-backed storage (Android). 토큰은 Keychain(iOS) 또는 Keystore 기반 저장소(Android)에 보관하세요.
  • Always verify the returned state before exchanging the code. code 를 교환하기 전에 돌아온 state 를 반드시 확인하세요.

9 Distribution (install packages) 배포 (설치 패키지)

  • Upload an Android .apk and an iOS .ipa when you register a Mobile App in Register App, or later in My Applications under Packages, where you can also replace or delete them. Android .apk 와 iOS .ipa 는 Register App 에서 Mobile App 을 등록할 때 올리거나, 나중에 My Applications 의 Packages 에서 올립니다. 교체·삭제도 같은 곳에서 합니다.
  • After the first upload, the app's download URL is set automatically to https://api.altscodex.com/v1/client/download/<client_id>. Deleting every package clears it. 첫 업로드가 끝나면 앱의 다운로드 URL 이 https://api.altscodex.com/v1/client/download/<client_id> 로 자동 설정됩니다. 패키지를 모두 삭제하면 비워집니다.
  • Each platform also gets its own URL, shown with a Copy button in the Packages panel. Share that URL when you want to point users to one platform. 플랫폼마다 따로 URL 이 생기며 Packages 패널에서 Copy 버튼으로 복사할 수 있습니다. 특정 플랫폼으로 안내하려면 그 URL 을 공유하세요.
    • Automatic: https://api.altscodex.com/v1/client/download/<client_id> sends iPhone, iPad and iPod to iOS and every other device to Android. If that platform has no package, it sends the one that exists. 자동: https://api.altscodex.com/v1/client/download/<client_id> 는 iPhone·iPad·iPod 이면 iOS, 그 외 기기는 Android 로 보냅니다. 해당 플랫폼 패키지가 없으면 있는 쪽으로 보냅니다.
    • Android: https://api.altscodex.com/v1/client/download/<client_id>/android Android: https://api.altscodex.com/v1/client/download/<client_id>/android
    • iOS: itms-services://?action=download-manifest&url=https%3A%2F%2Fapi.altscodex.com%2Fv1%2Fclient%2Fmanifest%2F<client_id>.plist (Enterprise or Ad-Hoc signed IPAs only) iOS: itms-services://?action=download-manifest&url=https%3A%2F%2Fapi.altscodex.com%2Fv1%2Fclient%2Fmanifest%2F<client_id>.plist (Enterprise·Ad-Hoc 서명 IPA 만)
  • The files themselves are served from https://files.altscodex.com/packages/…. 실제 파일은 https://files.altscodex.com/packages/… 에서 전송됩니다.
  • The maximum size is 200MB per file. iOS uploads need the app's bundle ID, such as com.example.app. 파일당 최대 크기는 200MB 입니다. iOS 업로드에는 com.example.app 같은 앱 번들 ID 가 필요합니다.
⚠️ iOS install requires Enterprise or Ad-Hoc signing ⚠️ iOS 설치는 Enterprise 또는 Ad-Hoc 서명이 필요
iOS over-the-air install works only for IPAs signed with an Enterprise or Ad-Hoc profile, and Ad-Hoc builds install only on devices registered in that profile. App Store-signed builds will not install. If your app ships through the App Store or Google Play, enter the store link as the Application Download URL instead of uploading a package. iOS OTA 설치는 Enterprise 또는 Ad-Hoc 프로필로 서명한 IPA 만 가능하며, Ad-Hoc 빌드는 그 프로필에 등록된 기기에만 설치됩니다. App Store 서명본은 설치되지 않습니다. App Store·Google Play 로 배포하는 앱은 패키지를 올리지 말고 Application Download URL 에 스토어 링크를 넣으세요.