
Shared API wrapping NAVER's native login SDKs for unified sign-in: preserves app-to-app/browser flows, maps errors into actionable codes, distinguishes Cancelled, avoids duplicate session state, offers optional branded button.
NAVER Login (네이버 아이디로 로그인) for Kotlin Multiplatform. One shared API, NAVER's own native
SDKs underneath — com.navercorp.nid:oauth on Android and NidThirdPartyLogin on iOS. Nothing about
the login itself is reimplemented here: the app-to-app handoff to the NAVER app, the browser fallback
when it is not installed, and the token storage are all NAVER's. Only the API you write against is
shared.
NaverLogin.configure(
NaverLoginConfig(clientId = "…", clientSecret = "…", clientName = "My App", urlScheme = "myapp"),
)
when (val result = NaverLogin.login()) {
is NaverLoginResult.Success -> useToken(result.accessToken.value)
NaverLoginResult.Cancelled -> Unit
is NaverLoginResult.Failure -> showError(result.message)
}Cancelled is its own result, so a user backing
out of a login never reaches your error handling. Both SDKs report it as a failure code; this one
does not.NaverLoginErrorCode, with each SDK's own code and message passed through untouched on
rawCode and rawMessage.<queries> entries and Activities.null, not "". NAVER returns an empty string for every profile field the
user declined. An empty string is a value; null is the truth.login() here returns
LoginInProgress instead.NaverLoginButton is NAVER's own mark, wording
and colours, measured off its published artwork. It is a separate artifact, so an app that does not
use Compose never pays for it.| Component | Requirement |
|---|---|
| Android | minSdk 24 — this library's floor. NAVER's own AAR declares 21, so 24 is this project's choice, not NAVER's limit |
| iOS | 13.0 — the floor NAVER's iOS SDK declares |
| Kotlin | 2.4.0+ |
| Xcode | 16+ |
| Compose Multiplatform |
1.11.1 — only if you take the optional naverloginkmp-compose button. The core library has no UI dependency at all |
| NAVER SDK | Android com.navercorp.nid:oauth:5.12.0, pulled in automatically · iOS naveridlogin-sdk-ios-swift 5.1.0, pulled in by this repository's Swift package |
You also need an application registered on the NAVER Developers console, with 네이버 아이디로 로그인 enabled and one service environment per platform you ship. Step 0 walks through that form field by field, and says where each value it hands back belongs in your code.
Nothing below works without this, and it is where every value in NaverLoginConfig comes from.
Go to developers.naver.com/apps/#/register and sign in with a NAVER account. The registration form is one page.
애플리케이션 이름 is not an internal label: it is the name users read on the consent screen when they are asked to hand your app their profile. Under 사용 API, tick 네이버 아이디로 로그인 — that is the API this library drives, and the rest of the list is NAVER's other open APIs.
Choosing the API opens a list of profile items, each to be requested as a 필수 항목 or an 추가 항목. The consent screen presents the first group as required and the second as optional, so a user can decline an optional item and still sign in.
This list is a ceiling rather than a request: a field you did not tick here never arrives, whatever you call. 회원이름, 이메일, 별명 and 프로필사진 are the ordinary ones, available to any application. Anything past those four needs a separate approval from NAVER, and until you have it the field comes back empty for every user.
| Console item | Field on NaverProfile
|
|---|---|
| 회원이름 | name |
| 이메일 | email |
| 별명 | nickname |
| 프로필사진 | profileImageUrl |
| 성별 | gender |
| 연령대 | age |
| 생일 | birthday |
| 출생연도 | birthYear |
| 휴대전화번호 | mobile |
This is why every field on NaverProfile except id is nullable. A null there means one
of three things and no code can tell them apart: the item was never selected here, NAVER has not
approved your application for it, or the user declined it. id is the exception because NAVER
returns it on every login.
Add an environment for each platform you ship, and both if you ship both. Each asks for one field that the SDK actually uses:
dev.yjyoon.naverloginkmp.sample for this repository's sample. NAVER identifies the Android app
by package name and nothing else. It never asks for a signing certificate fingerprint, unlike
Google Sign-In or Firebase — so there is no SHA-1 to paste, and no debug-versus-release pair to
register.CFBundleURLSchemes in your Info.plist
(step 3), and NaverLoginConfig.urlScheme. Pick something no other app
will claim: when two apps claim one scheme iOS awards it to whichever it likes, and the failures
that follow look random rather than like a configuration mistake.서비스 URL and 다운로드 URL are for the console's own listing — a homepage and a store link. Neither SDK reads them.
The application's overview page carries the two issued credentials. Those, plus the name you typed, are what the code needs:
| From the console | Where it goes | Why it matters |
|---|---|---|
| Client ID | NaverLoginConfig.clientId |
Identifies the application to NAVER. |
| Client Secret | NaverLoginConfig.clientSecret |
Ships inside your app — see below. |
| 애플리케이션 이름 | NaverLoginConfig.clientName |
What users read on the consent screen. Keep it identical to the console: NAVER's mobile web login screen shows the registered name rather than this one, so a mismatch is visible to users. |
The client secret is not optional, and it does not stay secret — both SDKs take it as an initialiser parameter, so it ends up in your APK and your IPA. There is one consequence you have to design your backend around, and it is written out in Your client secret ships inside your app.
With those four values — client ID, client secret, application name, and on iOS the URL scheme — the rest is installation.
// shared/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true // required — frameworks are dynamic by default
}
}
sourceSets {
commonMain.dependencies {
implementation("dev.yjyoon.naverloginkmp:naverloginkmp:1.0.0")
// Optional — NAVER's own login button, drawn to its guidelines.
implementation("dev.yjyoon.naverloginkmp:naverloginkmp-compose:1.0.0")
}
}
}naverloginkmp-compose is optional and additive: one composable, NaverLoginButton.
It is a separate artifact because taking it means taking Compose Multiplatform, and an app built on
Views or SwiftUI should not have to. It depends on the core artifact with api, so NaverLogin
itself comes along with it. Its iOS targets are iosArm64 and iosSimulatorArm64 only — Compose
Multiplatform publishes no Intel simulator target — while the core library also supports iosX64.
isStatic = true is not a preference. A dynamic framework has to resolve every symbol it references
while Kotlin links it, which is long before Xcode has fetched the Swift package that defines them.
A static one defers those symbols to your app's own link step, which is why step 2 happens in Xcode
and not in Gradle.
File ▸ Add Package Dependencies… → https://github.com/yjyoon-dev/naver-login-kmp → choose the
NaverLoginKmpShim product, and add it to your app target.
Pin it to the same version as the Gradle dependency — Exact Version 1.0.0. Releases are tagged
v1.0.0; Swift Package Manager understands the v prefix, so type the version without it.
NaverLoginKmpShim depends on NAVER's own iOS SDK, so Swift Package Manager fetches
naveridlogin-sdk-ios-swift for you and you do not add it yourself. Why the shim exists at all is
explained below.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>naver-login</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>naversearchapp</string>
<string>naversearchthirdlogin</string>
</array>The scheme in CFBundleURLSchemes is your app's own — it is where NAVER returns after an
app-to-app login, and the same string must appear as NaverLoginConfig.urlScheme and on the console.
Pick something nobody else will claim: iOS gives a scheme to whichever app it feels like when two
claim it, and the failure looks random rather than like a configuration mistake.
The two entries under LSApplicationQueriesSchemes are how the SDK asks whether the NAVER app is
there. Omit them and canOpenURL answers false with no error anywhere: every user silently gets
the browser flow, and isNaverAppInstalled() returns false on every device.
import Shared // your Kotlin framework, whatever you named it
ContentView()
.onOpenURL { _ = NaverLoginUrlHandler.shared.handle(url: $0) }NaverLoginUrlHandler is Kotlin, exported through your own shared framework — not something you
import from NaverLoginKmpShim. The shim is a build-time dependency that supplies the symbols this
library links against; nothing in your Swift code calls it directly.
That hands the callback URL back to NAVER's SDK, which is what finishes an app-to-app login. A
SwiftUI App is scene-based and UIKit delivers URLs to the scene, so .onOpenURL is the right hook —
AppDelegate.application(_:open:options:) is never called in an app shaped like that.
In a UIKit app, forward it from the app delegate instead:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
NaverLoginUrlHandler.shared.handle(url: url)
}Android needs no manifest changes at all.
// Once, at startup — Application.onCreate on Android, your app's initialiser on iOS, or any shared
// initialisation that runs before the first login. It does not suspend.
NaverLogin.configure(
NaverLoginConfig(
clientId = BuildConfig.NAVER_CLIENT_ID,
clientSecret = BuildConfig.NAVER_CLIENT_SECRET,
clientName = "My App",
urlScheme = "myapp", // iOS only; ignored on Android, required on iOS
),
)
suspend fun signIn() {
when (val result = NaverLogin.login()) {
is NaverLoginResult.Success -> {
val token = result.accessToken.value // send this to your backend to verify
val expiry = result.accessToken.expiresAtEpochMilliseconds
}
// The user changed their mind. Show nothing.
NaverLoginResult.Cancelled -> Unit
is NaverLoginResult.Failure -> when (result.code) {
NaverLoginErrorCode.Network -> retryLater()
NaverLoginErrorCode.NeedsNaverAppUpdate -> askUserToUpdateNaver()
NaverLoginErrorCode.Authentication -> reportMisconfiguration(result.rawCode, result.message)
else -> showError(result.message)
}
}
}A successful login does not mean the user saw a screen. When a refresh token is stored, both SDKs renew the access token and return without showing anything.
Asking again for the profile fields a user declined:
NaverLogin.login(NaverLoginRequest(prompt = NaverLoginPrompt.RepromptPermissions))Worth doing only when a field you genuinely need was refused. NAVER shows the consent screen again, and someone who said no once tends to notice being asked twice.
The profile, which is a separate network call because NAVER's login returns no profile of its own:
when (val result = NaverLogin.profile()) {
is NaverProfileResult.Success -> {
val id = result.profile.id // stable per application; key your accounts on this
val email = result.profile.email // null when the user did not share it
}
is NaverProfileResult.Failure -> showError(result.message)
}Signing out, disconnecting, and checking what this device holds:
NaverLogin.logout() // drops the tokens on this device; the grant with NAVER survives
NaverLogin.disconnect() // revokes the grant too — this is what account deletion needs
if (NaverLogin.isLoggedIn()) { /* a token exists here; it may still be expired */ }
val token: NaverAccessToken? = NaverLogin.currentAccessToken()logout() and disconnect() are not the same operation, and the difference is visible to users:
after logout() the connection is still listed on the user's NAVER security page, and the next
login() completes without them typing anything. disconnect() reaches the network, so it can fail —
and when it does, nothing was revoked.
Whether this device has the NAVER app, which decides the route a login takes. For deciding what to
show; login() works either way:
if (NaverLogin.isNaverAppInstalled()) { /* … */ }:naverloginkmp-compose is the button, and it is the whole module:
NaverLoginButton(onClick = { scope.launch { handle(NaverLogin.login()) } })NAVER's own N mark, NAVER's own wording for the reader's locale — 네이버 로그인 in Korean,
Log in with Naver everywhere else — and the colours, corner radius, mark size and spacing measured
off NAVER's published artwork.
The mark is NAVER's, taken from its official asset pack rather than redrawn, and it is a trademark: it is not covered by this project's Apache licence, and NAVER's brand guidelines apply to your application as much as to this one. NOTICE records exactly which file it came from and what was done to it.
@Composable
public fun NaverLoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: String? = NaverLoginButtonText.current(),
style: NaverLoginButtonStyle = NaverLoginButtonStyle.Green,
height: Dp = NaverLoginButtonDefaults.Height,
shape: Shape = NaverLoginButtonDefaults.shape(iconOnly = text == null),
textStyle: TextStyle = NaverLoginButtonDefaults.textStyle(height),
naverIcon: Painter = NaverLoginButtonDefaults.naverIcon(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
)style is Green or White — NAVER's two published treatments, both official and
interchangeable. Each has a light and a dark form, and the button picks between them from
isSystemInDarkTheme() rather than making you pass a third case, because the asset pack pairs them
that way.
There is no colour parameter, and that is deliberate. NAVER's guidelines name an unspecified
background colour, an unspecified label colour and a modified mark as violations, so the palette is
not yours to override. Pressed, hovered and disabled states change the button's opacity instead —
what lands on screen is still NAVER's own colour over your surface, and no fifth colour enters the
palette. The four palettes are readable on NaverLoginButtonColors for anyone drawing their own
button in UIKit or Views.
text = null gives the icon-only button, which the guidelines also permit: the default shape
becomes a circle, and the mark carries NAVER's wording as its accessibility label, so a screen reader
still announces what the button does rather than just "button". A label of your own has to still say
that the button logs in with NAVER — NaverLoginButtonText.Published is the list NAVER itself offers.
height is the one number everything else is derived from: mark size, leading inset, label gap
and text size are all ratios of it. NaverLoginButtonDefaults.MinHeight is 48 dp — the default height
too — and below it the mark stops clearing the 18 px floor NAVER sets. The button grows past height
if the reader's font-size setting demands it, rather than clipping the label.
Given no width the button wraps its content. Given a wider one — Modifier.fillMaxWidth(), a fixed
width, a stretching parent — the mark keeps its square at the leading inset and the label stays
centred in the button, which is how NAVER's own wide artwork is laid out. A label too long for the
room beside the mark ellipsises rather than pushing the mark out of its square.
Nothing to do. The application Context is picked up by an androidx.startup initialiser, so
configure works from shared code, and the Activity the SDK needs is this library's own.
login() must be called while your app is in the foreground. Android does not allow starting an
Activity from the background and gives no callback when it refuses, so a background call resolves to
Failure(Internal, …) after a timeout rather than suspending forever.
Beyond the one line of Swift, one function exists on iOS only:
// iosMain
val result = NaverLogin.reauthenticate()It forces a signed-in user to prove themselves again — for a payment screen, or before showing
something sensitive. NAVER's Android SDK has no equivalent, so it is deliberately absent from
NaverLoginPrompt rather than quietly doing something else on one platform.
| Type | What it is |
|---|---|
NaverLogin |
The entry point: configure, isConfigured, login, logout, disconnect, currentAccessToken, isLoggedIn, profile, isNaverAppInstalled
|
NaverLoginConfig |
Client ID, client secret, client name, and the iOS URL scheme |
NaverLoginRequest |
Per-login options — currently the prompt |
NaverLoginPrompt |
Default · RepromptPermissions
|
NaverLoginResult |
Success · Cancelled · Failure
|
NaverLogoutResult |
Success · Failure — no Cancelled, because there is nothing to cancel |
NaverProfileResult |
Success · Failure
|
NaverLoginErrorCode |
NotConfigured Network Server Authentication NoAuthenticationApp NeedsNaverAppUpdate LoginInProgress Internal
|
NaverAccessToken |
Token value and expiry, in epoch milliseconds |
NaverProfile |
id, nickname, name, email, gender, age, birthday, birthYear, profileImageUrl, mobile
|
From dev.yjyoon.naverloginkmp:naverloginkmp-compose, if you take it:
| Type | What it is |
|---|---|
NaverLoginButton |
The composable — see Compose |
NaverLoginButtonStyle |
Green · White
|
NaverLoginButtonText |
NAVER's published labels, plus of(languageTag) and the @Composable current() the button defaults to |
NaverLoginButtonColors |
The four palettes — light and dark for each style — exposed for buttons this library does not draw |
NaverLoginButtonDefaults |
Height, MinHeight, CornerRadius, BorderWidth, markSize, leadingInset, labelGap, shape, textStyle, naverIcon
|
login() never throws. Every outcome is a value:
public sealed interface NaverLoginResult {
public class Success(
public val accessToken: NaverAccessToken,
public val refreshToken: String? = null,
) : NaverLoginResult
public data object Cancelled : NaverLoginResult
public class Failure(
public val code: NaverLoginErrorCode,
public val message: String,
public val rawCode: String? = null, // the native SDK's own code, verbatim
public val rawMessage: String? = null,
) : NaverLoginResult
}The only exception that ever escapes is CancellationException, when your own coroutine is cancelled.
Success carries tokens and nothing else: NAVER's login returns no profile, and this library will not
make a network call you did not ask for. Call NaverLogin.profile() when you want one.
Every field on NaverProfile except id is nullable, and a null there means the user declined it
on the consent screen or your application was never approved for it — not that the request failed.
id is NAVER's identifier for that person within your application; the same person gets a
different one in a different application, and their real NAVER ID is never disclosed.
NaverAccessToken.expiresAtEpochMilliseconds is in milliseconds. Both native SDKs report the expiry
in seconds; the conversion happens here so the value matches every other timestamp in Kotlin.
NAVER requires the client secret in the app, on both platforms. It is a parameter of the native SDKs' own initialisers and there is no client-side flow that omits it, so it ends up in your APK and your IPA where anyone willing to unzip them can read it. Treat it as public.
The consequence that matters: a NAVER access token obtained on a device proves nothing to your
server. Anyone holding the secret can mint tokens for your client ID. Send the token to your backend
and verify it against NAVER there — https://openapi.naver.com/v1/nid/me with the token as a bearer
credential tells you who it belongs to — and trust the answer NAVER gives your server, never the one
the client sent.
Neither this library nor NAVER's own SDKs can fix this. It is written down because the alternative is letting people believe a secret shipped in an app is a secret.
NAVER's iOS SDK is pure Swift, and not one symbol in it is @objc. Kotlin/Native's interop goes
through Objective-C only, so cinterop cannot see the SDK at all — there is nothing to bind to.
NaverLoginKmpShim is this repository's answer: a small Swift package that wraps the parts of
NidOAuth this library uses in explicit @objc declarations, which Kotlin then binds through
cinterop as if they were an Objective-C SDK. It is source in swift/, published from the
same repository and tagged with the same versions, so the Kotlin artifact and the Swift package can
never drift apart. The cost is the one step you cannot skip: adding that SPM product to your app
target, so the symbols the static Kotlin framework left undefined have something to resolve against.
Keyed by what you actually see.
| Symptom | Cause |
|---|---|
Undefined symbols: _OBJC_CLASS_$_NLKNaverLogin when the app links |
The NaverLoginKmpShim product is not on the app target, or your Kotlin framework is dynamic — set isStatic = true, which is not the default. |
cannot find 'NaverLoginUrlHandler' in scope |
That type is Kotlin, not Swift: import your own shared framework, the one whose baseName you set in binaries.framework. It is not part of NaverLoginKmpShim. |
| The NAVER app finishes the login and your app never comes back | The URL is not being forwarded — .onOpenURL is not wired up, or CFBundleURLSchemes does not contain the exact string you passed as urlScheme. |
Login always opens the browser even though NAVER is installed, and isNaverAppInstalled() is false everywhere |
naversearchapp and naversearchthirdlogin are missing from LSApplicationQueriesSchemes. Nothing errors; canOpenURL returns false. |
Failure(Authentication, …) on every attempt, for every user |
The console does not match this build: wrong client ID or secret, an Android 패키지 이름 that is not this app's application ID, or a URL scheme that differs from the registered one. Step 0 is where all three come from. |
Failure(NotConfigured, …) |
configure was never called, or it ran after the first login(). It is cheap and idempotent — call it at startup. |
IllegalStateException: NaverLogin is already configured with client … |
configure was called twice with different values. Both SDKs are one-client-per-process, so this fails loudly rather than differing per platform. |
Failure(LoginInProgress, …) |
A login is already running. Concurrent logins are refused rather than queued — usually a double tap on the sign-in button. |
Failure(Internal, …) on Android with nothing on screen |
login() was called while the app was in the background. |
profile() returns Failure(Authentication, …)
|
The stored token expired. Call login() once to renew it and retry once — not in a loop. |
Every profile field except id is null
|
The items were never selected under 제공 정보 선택, your application is not approved for them, or the user declined them. NaverLoginPrompt.RepromptPermissions asks again; the other two you fix on the console. |
Failure(NeedsNaverAppUpdate, …) |
The installed NAVER app is too old for this flow. Nothing to fix in your build; tell the user. |
naverloginkmp-compose will not resolve for an iosX64 target |
Compose Multiplatform publishes no Intel simulator target, so this module has none either. The core artifact does — the button is the only part you lose. |
Unknown iOS simulator arch: 'x86_64' |
An Intel simulator slice was requested for a project without an iosX64 target. Add EXCLUDED_ARCHS[sdk=iphonesimulator*] = x86_64. |
Why an Android proxy Activity. NAVER's requestLogin(context, callback) ends in a bare
context.startActivity(intent) with no FLAG_ACTIVITY_NEW_TASK, so handing it an application
Context throws AndroidRuntimeException. Shared code has no Activity of its own, so this library
ships an invisible one that passes itself as the context — which is the usage NAVER documents.
Why configure does not suspend. Apps initialise things in Application.onCreate and in app
initialisers, and neither is a coroutine. Both SDKs set themselves up asynchronously, so this library
runs that setup on first use and makes every entry point wait for it. There is no window in which a
login fails merely for being early.
Why NaverLogin is an object. Both SDKs are process-wide singletons whose setup is one-shot, and
the iOS one reports a second initialisation as an error. An API that let you construct several clients
would be lying about what the platforms can do.
Why there are no scopes. NAVER has no per-login scope parameter. Which fields your application may
ask for is set on the console and granted by the user on the consent screen, so the only lever at
call time is NaverLoginPrompt.RepromptPermissions.
Why login returns no profile. NAVER's login gives you tokens; the profile is a separate HTTP call. Making it automatically would spend a request, and the user's patience, on data most sign-in screens never read.
Why reauthenticate() is iOS-only. NAVER's iOS SDK has it and its Android SDK does not. Putting it
in NaverLoginPrompt would mean one platform silently doing something else, so it lives in iosMain
where its absence elsewhere is a compile error rather than a surprise.
Why empty strings become null. NAVER's profile response uses "" for every field the user did
not share. Passing that through would make "declined" indistinguishable from "blank", and every
consumer would write the same takeIf { it.isNotEmpty() }.
sample/ is a Compose Multiplatform app running on both platforms, and the thing that
proves a real app can still link. Put your own client details in local.properties at the
repository root — it is gitignored, because a client secret in a public repository is not a secret:
naverClientId=your-client-id
naverClientSecret=your-client-secret
naverClientName=naver-login-kmp sample
naverUrlScheme=naverloginkmpsampleThe build turns those into SAMPLE_NAVER_* constants in commonMain, so both entry points read the
same values. Three sources are consulted, in this order: a Gradle property (-PnaverClientId=…, or
ORG_GRADLE_PROJECT_naverClientId in CI), then local.properties, then an environment variable
(NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, NAVER_CLIENT_NAME, NAVER_URL_SCHEME). The property
wins, so a command line can override the file; the environment variable is the last resort, for a
machine with no file on disk. Leave them all out and every call answers NotConfigured rather than
failing the build.
Your application on the NAVER Developers console has to declare what this sample actually ships:
| Console field | Value for this sample |
|---|---|
| Android 패키지 이름 | dev.yjyoon.naverloginkmp.sample |
| iOS URL Scheme |
naverloginkmpsample (already in sample/iosApp/iosApp/Info.plist) |
The sample's iOS bundle identifier is whatever you set in
sample/iosApp/Configuration/Config.xcconfig, and it is registered nowhere: NAVER identifies an iOS
app by URL scheme.
Then:
./gradlew :sample:composeApp:installDebug # Android
open sample/iosApp/iosApp.xcodeproj # iOSGenerated with Dokka and published to https://yjyoon-dev.github.io/naver-login-kmp/ on every release.
Issues and pull requests are welcome — see CONTRIBUTING.md for how the project is laid out, which parts look wrong but are not, and what CI checks.
Copyright 2026 yjyoon
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
NAVER, 네이버, "네이버 아이디로 로그인" and the NAVER "N" mark are trademarks of NAVER Corporation.
The mark drawn by naverloginkmp-compose comes from NAVER's official button asset pack, is not
covered by the Apache licence above, and is not licensed to you by this project — NOTICE
records the file it came from, what was done to it, and the terms it is included under. NAVER's brand
guidelines apply to your application as much as to this one. This is an independent open-source
project and is not affiliated with, endorsed by, or sponsored by NAVER Corporation.
NAVER Login (네이버 아이디로 로그인) for Kotlin Multiplatform. One shared API, NAVER's own native
SDKs underneath — com.navercorp.nid:oauth on Android and NidThirdPartyLogin on iOS. Nothing about
the login itself is reimplemented here: the app-to-app handoff to the NAVER app, the browser fallback
when it is not installed, and the token storage are all NAVER's. Only the API you write against is
shared.
NaverLogin.configure(
NaverLoginConfig(clientId = "…", clientSecret = "…", clientName = "My App", urlScheme = "myapp"),
)
when (val result = NaverLogin.login()) {
is NaverLoginResult.Success -> useToken(result.accessToken.value)
NaverLoginResult.Cancelled -> Unit
is NaverLoginResult.Failure -> showError(result.message)
}Cancelled is its own result, so a user backing
out of a login never reaches your error handling. Both SDKs report it as a failure code; this one
does not.NaverLoginErrorCode, with each SDK's own code and message passed through untouched on
rawCode and rawMessage.<queries> entries and Activities.null, not "". NAVER returns an empty string for every profile field the
user declined. An empty string is a value; null is the truth.login() here returns
LoginInProgress instead.NaverLoginButton is NAVER's own mark, wording
and colours, measured off its published artwork. It is a separate artifact, so an app that does not
use Compose never pays for it.| Component | Requirement |
|---|---|
| Android | minSdk 24 — this library's floor. NAVER's own AAR declares 21, so 24 is this project's choice, not NAVER's limit |
| iOS | 13.0 — the floor NAVER's iOS SDK declares |
| Kotlin | 2.4.0+ |
| Xcode | 16+ |
| Compose Multiplatform |
1.11.1 — only if you take the optional naverloginkmp-compose button. The core library has no UI dependency at all |
| NAVER SDK | Android com.navercorp.nid:oauth:5.12.0, pulled in automatically · iOS naveridlogin-sdk-ios-swift 5.1.0, pulled in by this repository's Swift package |
You also need an application registered on the NAVER Developers console, with 네이버 아이디로 로그인 enabled and one service environment per platform you ship. Step 0 walks through that form field by field, and says where each value it hands back belongs in your code.
Nothing below works without this, and it is where every value in NaverLoginConfig comes from.
Go to developers.naver.com/apps/#/register and sign in with a NAVER account. The registration form is one page.
애플리케이션 이름 is not an internal label: it is the name users read on the consent screen when they are asked to hand your app their profile. Under 사용 API, tick 네이버 아이디로 로그인 — that is the API this library drives, and the rest of the list is NAVER's other open APIs.
Choosing the API opens a list of profile items, each to be requested as a 필수 항목 or an 추가 항목. The consent screen presents the first group as required and the second as optional, so a user can decline an optional item and still sign in.
This list is a ceiling rather than a request: a field you did not tick here never arrives, whatever you call. 회원이름, 이메일, 별명 and 프로필사진 are the ordinary ones, available to any application. Anything past those four needs a separate approval from NAVER, and until you have it the field comes back empty for every user.
| Console item | Field on NaverProfile
|
|---|---|
| 회원이름 | name |
| 이메일 | email |
| 별명 | nickname |
| 프로필사진 | profileImageUrl |
| 성별 | gender |
| 연령대 | age |
| 생일 | birthday |
| 출생연도 | birthYear |
| 휴대전화번호 | mobile |
This is why every field on NaverProfile except id is nullable. A null there means one
of three things and no code can tell them apart: the item was never selected here, NAVER has not
approved your application for it, or the user declined it. id is the exception because NAVER
returns it on every login.
Add an environment for each platform you ship, and both if you ship both. Each asks for one field that the SDK actually uses:
dev.yjyoon.naverloginkmp.sample for this repository's sample. NAVER identifies the Android app
by package name and nothing else. It never asks for a signing certificate fingerprint, unlike
Google Sign-In or Firebase — so there is no SHA-1 to paste, and no debug-versus-release pair to
register.CFBundleURLSchemes in your Info.plist
(step 3), and NaverLoginConfig.urlScheme. Pick something no other app
will claim: when two apps claim one scheme iOS awards it to whichever it likes, and the failures
that follow look random rather than like a configuration mistake.서비스 URL and 다운로드 URL are for the console's own listing — a homepage and a store link. Neither SDK reads them.
The application's overview page carries the two issued credentials. Those, plus the name you typed, are what the code needs:
| From the console | Where it goes | Why it matters |
|---|---|---|
| Client ID | NaverLoginConfig.clientId |
Identifies the application to NAVER. |
| Client Secret | NaverLoginConfig.clientSecret |
Ships inside your app — see below. |
| 애플리케이션 이름 | NaverLoginConfig.clientName |
What users read on the consent screen. Keep it identical to the console: NAVER's mobile web login screen shows the registered name rather than this one, so a mismatch is visible to users. |
The client secret is not optional, and it does not stay secret — both SDKs take it as an initialiser parameter, so it ends up in your APK and your IPA. There is one consequence you have to design your backend around, and it is written out in Your client secret ships inside your app.
With those four values — client ID, client secret, application name, and on iOS the URL scheme — the rest is installation.
// shared/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true // required — frameworks are dynamic by default
}
}
sourceSets {
commonMain.dependencies {
implementation("dev.yjyoon.naverloginkmp:naverloginkmp:1.0.0")
// Optional — NAVER's own login button, drawn to its guidelines.
implementation("dev.yjyoon.naverloginkmp:naverloginkmp-compose:1.0.0")
}
}
}naverloginkmp-compose is optional and additive: one composable, NaverLoginButton.
It is a separate artifact because taking it means taking Compose Multiplatform, and an app built on
Views or SwiftUI should not have to. It depends on the core artifact with api, so NaverLogin
itself comes along with it. Its iOS targets are iosArm64 and iosSimulatorArm64 only — Compose
Multiplatform publishes no Intel simulator target — while the core library also supports iosX64.
isStatic = true is not a preference. A dynamic framework has to resolve every symbol it references
while Kotlin links it, which is long before Xcode has fetched the Swift package that defines them.
A static one defers those symbols to your app's own link step, which is why step 2 happens in Xcode
and not in Gradle.
File ▸ Add Package Dependencies… → https://github.com/yjyoon-dev/naver-login-kmp → choose the
NaverLoginKmpShim product, and add it to your app target.
Pin it to the same version as the Gradle dependency — Exact Version 1.0.0. Releases are tagged
v1.0.0; Swift Package Manager understands the v prefix, so type the version without it.
NaverLoginKmpShim depends on NAVER's own iOS SDK, so Swift Package Manager fetches
naveridlogin-sdk-ios-swift for you and you do not add it yourself. Why the shim exists at all is
explained below.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>naver-login</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>naversearchapp</string>
<string>naversearchthirdlogin</string>
</array>The scheme in CFBundleURLSchemes is your app's own — it is where NAVER returns after an
app-to-app login, and the same string must appear as NaverLoginConfig.urlScheme and on the console.
Pick something nobody else will claim: iOS gives a scheme to whichever app it feels like when two
claim it, and the failure looks random rather than like a configuration mistake.
The two entries under LSApplicationQueriesSchemes are how the SDK asks whether the NAVER app is
there. Omit them and canOpenURL answers false with no error anywhere: every user silently gets
the browser flow, and isNaverAppInstalled() returns false on every device.
import Shared // your Kotlin framework, whatever you named it
ContentView()
.onOpenURL { _ = NaverLoginUrlHandler.shared.handle(url: $0) }NaverLoginUrlHandler is Kotlin, exported through your own shared framework — not something you
import from NaverLoginKmpShim. The shim is a build-time dependency that supplies the symbols this
library links against; nothing in your Swift code calls it directly.
That hands the callback URL back to NAVER's SDK, which is what finishes an app-to-app login. A
SwiftUI App is scene-based and UIKit delivers URLs to the scene, so .onOpenURL is the right hook —
AppDelegate.application(_:open:options:) is never called in an app shaped like that.
In a UIKit app, forward it from the app delegate instead:
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
NaverLoginUrlHandler.shared.handle(url: url)
}Android needs no manifest changes at all.
// Once, at startup — Application.onCreate on Android, your app's initialiser on iOS, or any shared
// initialisation that runs before the first login. It does not suspend.
NaverLogin.configure(
NaverLoginConfig(
clientId = BuildConfig.NAVER_CLIENT_ID,
clientSecret = BuildConfig.NAVER_CLIENT_SECRET,
clientName = "My App",
urlScheme = "myapp", // iOS only; ignored on Android, required on iOS
),
)
suspend fun signIn() {
when (val result = NaverLogin.login()) {
is NaverLoginResult.Success -> {
val token = result.accessToken.value // send this to your backend to verify
val expiry = result.accessToken.expiresAtEpochMilliseconds
}
// The user changed their mind. Show nothing.
NaverLoginResult.Cancelled -> Unit
is NaverLoginResult.Failure -> when (result.code) {
NaverLoginErrorCode.Network -> retryLater()
NaverLoginErrorCode.NeedsNaverAppUpdate -> askUserToUpdateNaver()
NaverLoginErrorCode.Authentication -> reportMisconfiguration(result.rawCode, result.message)
else -> showError(result.message)
}
}
}A successful login does not mean the user saw a screen. When a refresh token is stored, both SDKs renew the access token and return without showing anything.
Asking again for the profile fields a user declined:
NaverLogin.login(NaverLoginRequest(prompt = NaverLoginPrompt.RepromptPermissions))Worth doing only when a field you genuinely need was refused. NAVER shows the consent screen again, and someone who said no once tends to notice being asked twice.
The profile, which is a separate network call because NAVER's login returns no profile of its own:
when (val result = NaverLogin.profile()) {
is NaverProfileResult.Success -> {
val id = result.profile.id // stable per application; key your accounts on this
val email = result.profile.email // null when the user did not share it
}
is NaverProfileResult.Failure -> showError(result.message)
}Signing out, disconnecting, and checking what this device holds:
NaverLogin.logout() // drops the tokens on this device; the grant with NAVER survives
NaverLogin.disconnect() // revokes the grant too — this is what account deletion needs
if (NaverLogin.isLoggedIn()) { /* a token exists here; it may still be expired */ }
val token: NaverAccessToken? = NaverLogin.currentAccessToken()logout() and disconnect() are not the same operation, and the difference is visible to users:
after logout() the connection is still listed on the user's NAVER security page, and the next
login() completes without them typing anything. disconnect() reaches the network, so it can fail —
and when it does, nothing was revoked.
Whether this device has the NAVER app, which decides the route a login takes. For deciding what to
show; login() works either way:
if (NaverLogin.isNaverAppInstalled()) { /* … */ }:naverloginkmp-compose is the button, and it is the whole module:
NaverLoginButton(onClick = { scope.launch { handle(NaverLogin.login()) } })NAVER's own N mark, NAVER's own wording for the reader's locale — 네이버 로그인 in Korean,
Log in with Naver everywhere else — and the colours, corner radius, mark size and spacing measured
off NAVER's published artwork.
The mark is NAVER's, taken from its official asset pack rather than redrawn, and it is a trademark: it is not covered by this project's Apache licence, and NAVER's brand guidelines apply to your application as much as to this one. NOTICE records exactly which file it came from and what was done to it.
@Composable
public fun NaverLoginButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: String? = NaverLoginButtonText.current(),
style: NaverLoginButtonStyle = NaverLoginButtonStyle.Green,
height: Dp = NaverLoginButtonDefaults.Height,
shape: Shape = NaverLoginButtonDefaults.shape(iconOnly = text == null),
textStyle: TextStyle = NaverLoginButtonDefaults.textStyle(height),
naverIcon: Painter = NaverLoginButtonDefaults.naverIcon(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
)style is Green or White — NAVER's two published treatments, both official and
interchangeable. Each has a light and a dark form, and the button picks between them from
isSystemInDarkTheme() rather than making you pass a third case, because the asset pack pairs them
that way.
There is no colour parameter, and that is deliberate. NAVER's guidelines name an unspecified
background colour, an unspecified label colour and a modified mark as violations, so the palette is
not yours to override. Pressed, hovered and disabled states change the button's opacity instead —
what lands on screen is still NAVER's own colour over your surface, and no fifth colour enters the
palette. The four palettes are readable on NaverLoginButtonColors for anyone drawing their own
button in UIKit or Views.
text = null gives the icon-only button, which the guidelines also permit: the default shape
becomes a circle, and the mark carries NAVER's wording as its accessibility label, so a screen reader
still announces what the button does rather than just "button". A label of your own has to still say
that the button logs in with NAVER — NaverLoginButtonText.Published is the list NAVER itself offers.
height is the one number everything else is derived from: mark size, leading inset, label gap
and text size are all ratios of it. NaverLoginButtonDefaults.MinHeight is 48 dp — the default height
too — and below it the mark stops clearing the 18 px floor NAVER sets. The button grows past height
if the reader's font-size setting demands it, rather than clipping the label.
Given no width the button wraps its content. Given a wider one — Modifier.fillMaxWidth(), a fixed
width, a stretching parent — the mark keeps its square at the leading inset and the label stays
centred in the button, which is how NAVER's own wide artwork is laid out. A label too long for the
room beside the mark ellipsises rather than pushing the mark out of its square.
Nothing to do. The application Context is picked up by an androidx.startup initialiser, so
configure works from shared code, and the Activity the SDK needs is this library's own.
login() must be called while your app is in the foreground. Android does not allow starting an
Activity from the background and gives no callback when it refuses, so a background call resolves to
Failure(Internal, …) after a timeout rather than suspending forever.
Beyond the one line of Swift, one function exists on iOS only:
// iosMain
val result = NaverLogin.reauthenticate()It forces a signed-in user to prove themselves again — for a payment screen, or before showing
something sensitive. NAVER's Android SDK has no equivalent, so it is deliberately absent from
NaverLoginPrompt rather than quietly doing something else on one platform.
| Type | What it is |
|---|---|
NaverLogin |
The entry point: configure, isConfigured, login, logout, disconnect, currentAccessToken, isLoggedIn, profile, isNaverAppInstalled
|
NaverLoginConfig |
Client ID, client secret, client name, and the iOS URL scheme |
NaverLoginRequest |
Per-login options — currently the prompt |
NaverLoginPrompt |
Default · RepromptPermissions
|
NaverLoginResult |
Success · Cancelled · Failure
|
NaverLogoutResult |
Success · Failure — no Cancelled, because there is nothing to cancel |
NaverProfileResult |
Success · Failure
|
NaverLoginErrorCode |
NotConfigured Network Server Authentication NoAuthenticationApp NeedsNaverAppUpdate LoginInProgress Internal
|
NaverAccessToken |
Token value and expiry, in epoch milliseconds |
NaverProfile |
id, nickname, name, email, gender, age, birthday, birthYear, profileImageUrl, mobile
|
From dev.yjyoon.naverloginkmp:naverloginkmp-compose, if you take it:
| Type | What it is |
|---|---|
NaverLoginButton |
The composable — see Compose |
NaverLoginButtonStyle |
Green · White
|
NaverLoginButtonText |
NAVER's published labels, plus of(languageTag) and the @Composable current() the button defaults to |
NaverLoginButtonColors |
The four palettes — light and dark for each style — exposed for buttons this library does not draw |
NaverLoginButtonDefaults |
Height, MinHeight, CornerRadius, BorderWidth, markSize, leadingInset, labelGap, shape, textStyle, naverIcon
|
login() never throws. Every outcome is a value:
public sealed interface NaverLoginResult {
public class Success(
public val accessToken: NaverAccessToken,
public val refreshToken: String? = null,
) : NaverLoginResult
public data object Cancelled : NaverLoginResult
public class Failure(
public val code: NaverLoginErrorCode,
public val message: String,
public val rawCode: String? = null, // the native SDK's own code, verbatim
public val rawMessage: String? = null,
) : NaverLoginResult
}The only exception that ever escapes is CancellationException, when your own coroutine is cancelled.
Success carries tokens and nothing else: NAVER's login returns no profile, and this library will not
make a network call you did not ask for. Call NaverLogin.profile() when you want one.
Every field on NaverProfile except id is nullable, and a null there means the user declined it
on the consent screen or your application was never approved for it — not that the request failed.
id is NAVER's identifier for that person within your application; the same person gets a
different one in a different application, and their real NAVER ID is never disclosed.
NaverAccessToken.expiresAtEpochMilliseconds is in milliseconds. Both native SDKs report the expiry
in seconds; the conversion happens here so the value matches every other timestamp in Kotlin.
NAVER requires the client secret in the app, on both platforms. It is a parameter of the native SDKs' own initialisers and there is no client-side flow that omits it, so it ends up in your APK and your IPA where anyone willing to unzip them can read it. Treat it as public.
The consequence that matters: a NAVER access token obtained on a device proves nothing to your
server. Anyone holding the secret can mint tokens for your client ID. Send the token to your backend
and verify it against NAVER there — https://openapi.naver.com/v1/nid/me with the token as a bearer
credential tells you who it belongs to — and trust the answer NAVER gives your server, never the one
the client sent.
Neither this library nor NAVER's own SDKs can fix this. It is written down because the alternative is letting people believe a secret shipped in an app is a secret.
NAVER's iOS SDK is pure Swift, and not one symbol in it is @objc. Kotlin/Native's interop goes
through Objective-C only, so cinterop cannot see the SDK at all — there is nothing to bind to.
NaverLoginKmpShim is this repository's answer: a small Swift package that wraps the parts of
NidOAuth this library uses in explicit @objc declarations, which Kotlin then binds through
cinterop as if they were an Objective-C SDK. It is source in swift/, published from the
same repository and tagged with the same versions, so the Kotlin artifact and the Swift package can
never drift apart. The cost is the one step you cannot skip: adding that SPM product to your app
target, so the symbols the static Kotlin framework left undefined have something to resolve against.
Keyed by what you actually see.
| Symptom | Cause |
|---|---|
Undefined symbols: _OBJC_CLASS_$_NLKNaverLogin when the app links |
The NaverLoginKmpShim product is not on the app target, or your Kotlin framework is dynamic — set isStatic = true, which is not the default. |
cannot find 'NaverLoginUrlHandler' in scope |
That type is Kotlin, not Swift: import your own shared framework, the one whose baseName you set in binaries.framework. It is not part of NaverLoginKmpShim. |
| The NAVER app finishes the login and your app never comes back | The URL is not being forwarded — .onOpenURL is not wired up, or CFBundleURLSchemes does not contain the exact string you passed as urlScheme. |
Login always opens the browser even though NAVER is installed, and isNaverAppInstalled() is false everywhere |
naversearchapp and naversearchthirdlogin are missing from LSApplicationQueriesSchemes. Nothing errors; canOpenURL returns false. |
Failure(Authentication, …) on every attempt, for every user |
The console does not match this build: wrong client ID or secret, an Android 패키지 이름 that is not this app's application ID, or a URL scheme that differs from the registered one. Step 0 is where all three come from. |
Failure(NotConfigured, …) |
configure was never called, or it ran after the first login(). It is cheap and idempotent — call it at startup. |
IllegalStateException: NaverLogin is already configured with client … |
configure was called twice with different values. Both SDKs are one-client-per-process, so this fails loudly rather than differing per platform. |
Failure(LoginInProgress, …) |
A login is already running. Concurrent logins are refused rather than queued — usually a double tap on the sign-in button. |
Failure(Internal, …) on Android with nothing on screen |
login() was called while the app was in the background. |
profile() returns Failure(Authentication, …)
|
The stored token expired. Call login() once to renew it and retry once — not in a loop. |
Every profile field except id is null
|
The items were never selected under 제공 정보 선택, your application is not approved for them, or the user declined them. NaverLoginPrompt.RepromptPermissions asks again; the other two you fix on the console. |
Failure(NeedsNaverAppUpdate, …) |
The installed NAVER app is too old for this flow. Nothing to fix in your build; tell the user. |
naverloginkmp-compose will not resolve for an iosX64 target |
Compose Multiplatform publishes no Intel simulator target, so this module has none either. The core artifact does — the button is the only part you lose. |
Unknown iOS simulator arch: 'x86_64' |
An Intel simulator slice was requested for a project without an iosX64 target. Add EXCLUDED_ARCHS[sdk=iphonesimulator*] = x86_64. |
Why an Android proxy Activity. NAVER's requestLogin(context, callback) ends in a bare
context.startActivity(intent) with no FLAG_ACTIVITY_NEW_TASK, so handing it an application
Context throws AndroidRuntimeException. Shared code has no Activity of its own, so this library
ships an invisible one that passes itself as the context — which is the usage NAVER documents.
Why configure does not suspend. Apps initialise things in Application.onCreate and in app
initialisers, and neither is a coroutine. Both SDKs set themselves up asynchronously, so this library
runs that setup on first use and makes every entry point wait for it. There is no window in which a
login fails merely for being early.
Why NaverLogin is an object. Both SDKs are process-wide singletons whose setup is one-shot, and
the iOS one reports a second initialisation as an error. An API that let you construct several clients
would be lying about what the platforms can do.
Why there are no scopes. NAVER has no per-login scope parameter. Which fields your application may
ask for is set on the console and granted by the user on the consent screen, so the only lever at
call time is NaverLoginPrompt.RepromptPermissions.
Why login returns no profile. NAVER's login gives you tokens; the profile is a separate HTTP call. Making it automatically would spend a request, and the user's patience, on data most sign-in screens never read.
Why reauthenticate() is iOS-only. NAVER's iOS SDK has it and its Android SDK does not. Putting it
in NaverLoginPrompt would mean one platform silently doing something else, so it lives in iosMain
where its absence elsewhere is a compile error rather than a surprise.
Why empty strings become null. NAVER's profile response uses "" for every field the user did
not share. Passing that through would make "declined" indistinguishable from "blank", and every
consumer would write the same takeIf { it.isNotEmpty() }.
sample/ is a Compose Multiplatform app running on both platforms, and the thing that
proves a real app can still link. Put your own client details in local.properties at the
repository root — it is gitignored, because a client secret in a public repository is not a secret:
naverClientId=your-client-id
naverClientSecret=your-client-secret
naverClientName=naver-login-kmp sample
naverUrlScheme=naverloginkmpsampleThe build turns those into SAMPLE_NAVER_* constants in commonMain, so both entry points read the
same values. Three sources are consulted, in this order: a Gradle property (-PnaverClientId=…, or
ORG_GRADLE_PROJECT_naverClientId in CI), then local.properties, then an environment variable
(NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, NAVER_CLIENT_NAME, NAVER_URL_SCHEME). The property
wins, so a command line can override the file; the environment variable is the last resort, for a
machine with no file on disk. Leave them all out and every call answers NotConfigured rather than
failing the build.
Your application on the NAVER Developers console has to declare what this sample actually ships:
| Console field | Value for this sample |
|---|---|
| Android 패키지 이름 | dev.yjyoon.naverloginkmp.sample |
| iOS URL Scheme |
naverloginkmpsample (already in sample/iosApp/iosApp/Info.plist) |
The sample's iOS bundle identifier is whatever you set in
sample/iosApp/Configuration/Config.xcconfig, and it is registered nowhere: NAVER identifies an iOS
app by URL scheme.
Then:
./gradlew :sample:composeApp:installDebug # Android
open sample/iosApp/iosApp.xcodeproj # iOSGenerated with Dokka and published to https://yjyoon-dev.github.io/naver-login-kmp/ on every release.
Issues and pull requests are welcome — see CONTRIBUTING.md for how the project is laid out, which parts look wrong but are not, and what CI checks.
Copyright 2026 yjyoon
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
NAVER, 네이버, "네이버 아이디로 로그인" and the NAVER "N" mark are trademarks of NAVER Corporation.
The mark drawn by naverloginkmp-compose comes from NAVER's official button asset pack, is not
covered by the Apache licence above, and is not licensed to you by this project — NOTICE
records the file it came from, what was done to it, and the terms it is included under. NAVER's brand
guidelines apply to your application as much as to this one. This is an independent open-source
project and is not affiliated with, endorsed by, or sponsored by NAVER Corporation.