Single Sign-On (SSO)

OIDC OAuth2 SSO SAML

Single Sign-On (SSO)은 사용자가 단일 자격 증명 세트를 사용하여 여러 애플리케이션에서 인증할 수 있도록 합니다. 이 플러그인은 OpenID Connect (OIDC), OAuth2 제공자 및 SAML 2.0을 지원합니다.

설치

플러그인 설치

npm install @better-auth/sso

서버에 플러그인 추가

auth.ts
import { betterAuth } from "better-auth"
import { sso } from "@better-auth/sso";

const auth = betterAuth({
    plugins: [ 
        sso() 
    ] 
})

데이터베이스 마이그레이션

마이그레이션을 실행하거나 스키마를 생성하여 필요한 필드와 테이블을 데이터베이스에 추가하세요.

npx @better-auth/cli migrate
npx @better-auth/cli generate

수동으로 필드를 추가하려면 스키마 섹션을 참조하세요.

클라이언트 플러그인 추가

auth-client.ts
import { createAuthClient } from "better-auth/client"
import { ssoClient } from "@better-auth/sso/client"

const authClient = createAuthClient({
    plugins: [ 
        ssoClient() 
    ] 
})

사용법

OIDC 제공자 등록

OIDC 제공자를 등록하려면 registerSSOProvider 엔드포인트를 사용하고 제공자에 필요한 구성 세부 정보를 제공하세요.

리디렉션 URL은 제공자 ID를 사용하여 자동으로 생성됩니다. 예를 들어 제공자 ID가 hydra인 경우 리디렉션 URL은 {baseURL}/api/auth/sso/callback/hydra가 됩니다. 참고로 /api/auth는 base path 구성에 따라 달라질 수 있습니다.

예제

register-oidc-provider.ts
import { authClient } from "@/lib/auth-client";

// OIDC 구성으로 등록
await authClient.sso.register({
    providerId: "example-provider",
    issuer: "https://idp.example.com",
    domain: "example.com",
    oidcConfig: {
        clientId: "client-id",
        clientSecret: "client-secret",
        authorizationEndpoint: "https://idp.example.com/authorize",
        tokenEndpoint: "https://idp.example.com/token",
        jwksEndpoint: "https://idp.example.com/jwks",
        discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
        scopes: ["openid", "email", "profile"],
        pkce: true,
        mapping: {
            id: "sub",
            email: "email",
            emailVerified: "email_verified",
            name: "name",
            image: "picture",
            extraFields: {
                department: "department",
                role: "role"
            }
        }
    }
});
register-oidc-provider.ts
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
    body: {
        providerId: "example-provider",
        issuer: "https://idp.example.com",
        domain: "example.com",
        oidcConfig: {
            clientId: "your-client-id",
            clientSecret: "your-client-secret",
            authorizationEndpoint: "https://idp.example.com/authorize",
            tokenEndpoint: "https://idp.example.com/token",
            jwksEndpoint: "https://idp.example.com/jwks",
            discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
            scopes: ["openid", "email", "profile"],
            pkce: true,
            mapping: {
                id: "sub",
                email: "email",
                emailVerified: "email_verified",
                name: "name",
                image: "picture",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    },
    headers,
});

SAML 제공자 등록

SAML 제공자를 등록하려면 SAML 구성 세부 정보와 함께 registerSSOProvider 엔드포인트를 사용하세요. 제공자는 Service Provider (SP)로 작동하여 Identity Provider (IdP)와 통합됩니다.

register-saml-provider.ts
import { authClient } from "@/lib/auth-client";

await authClient.sso.register({
    providerId: "saml-provider",
    issuer: "https://idp.example.com",
    domain: "example.com",
    samlConfig: {
        entryPoint: "https://idp.example.com/sso",
        cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
        callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
        audience: "https://yourapp.com",
        wantAssertionsSigned: true,
        signatureAlgorithm: "sha256",
        digestAlgorithm: "sha256",
        identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
        idpMetadata: {
            metadata: "<!-- IdP Metadata XML -->",
            privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
            privateKeyPass: "your-private-key-password",
            isAssertionEncrypted: true,
            encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
            encPrivateKeyPass: "your-encryption-key-password"
        },
        spMetadata: {
            metadata: "<!-- SP Metadata XML -->",
            binding: "post",
            privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
            privateKeyPass: "your-sp-private-key-password",
            isAssertionEncrypted: true,
            encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
            encPrivateKeyPass: "your-sp-encryption-key-password"
        },
        mapping: {
            id: "nameID",
            email: "email",
            name: "displayName",
            firstName: "givenName",
            lastName: "surname",
            emailVerified: "email_verified",
            extraFields: {
                department: "department",
                role: "role"
            }
        }
    }
});
register-saml-provider.ts
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
    body: {
        providerId: "saml-provider",
        issuer: "https://idp.example.com",
        domain: "example.com",
        samlConfig: {
            entryPoint: "https://idp.example.com/sso",
            cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
            callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
            audience: "https://yourapp.com",
            wantAssertionsSigned: true,
            signatureAlgorithm: "sha256",
            digestAlgorithm: "sha256",
            identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
            idpMetadata: {
                metadata: "<!-- IdP Metadata XML -->",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-encryption-key-password"
            },
            spMetadata: {
                metadata: "<!-- SP Metadata XML -->",
                binding: "post",
                privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                privateKeyPass: "your-sp-private-key-password",
                isAssertionEncrypted: true,
                encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
                encPrivateKeyPass: "your-sp-encryption-key-password"
            },
            mapping: {
                id: "nameID",
                email: "email",
                name: "displayName",
                firstName: "givenName",
                lastName: "surname",
                emailVerified: "email_verified",
                extraFields: {
                    department: "department",
                    role: "role"
                }
            }
        }
    },
    headers,
});

Service Provider 메타데이터 가져오기

SAML 제공자의 경우, Identity Provider에서 구성해야 하는 Service Provider 메타데이터 XML을 검색할 수 있습니다:

get-sp-metadata.ts
const response = await auth.api.spMetadata({
    query: {
        providerId: "saml-provider",
        format: "xml" // 또는 "json"
    }
});

const metadataXML = await response.text();
console.log(metadataXML);

SSO로 로그인

SSO 제공자로 로그인하려면 signIn.sso를 호출할 수 있습니다.

도메인 매칭을 통해 이메일을 사용하여 로그인할 수 있습니다:

sign-in.ts
const res = await authClient.signIn.sso({
    email: "user@example.com",
    callbackURL: "/dashboard",
});

또는 도메인을 지정할 수 있습니다:

sign-in-domain.ts
const res = await authClient.signIn.sso({
    domain: "example.com",
    callbackURL: "/dashboard",
});

제공자가 조직과 연결된 경우 조직 슬러그를 사용하여 로그인할 수도 있습니다:

sign-in-org.ts
const res = await authClient.signIn.sso({
    organizationSlug: "example-org",
    callbackURL: "/dashboard",
});

또는 제공자의 ID를 사용하여 로그인할 수 있습니다:

sign-in-provider-id.ts
const res = await authClient.signIn.sso({
    providerId: "example-provider-id",
    callbackURL: "/dashboard",
});

서버 API를 사용하려면 signInSSO를 사용할 수 있습니다:

sign-in-org.ts
const res = await auth.api.signInSSO({
    body: {
        organizationSlug: "example-org",
        callbackURL: "/dashboard",
    }
});

전체 메서드

POST
/sign-in/sso
const { data, error } = await authClient.signIn.sso({    email: "john@example.com",    organizationSlug: "example-org",    providerId: "example-provider",    domain: "example.com",    callbackURL: "https://example.com/callback", // required    errorCallbackURL: "https://example.com/callback",    newUserCallbackURL: "https://example.com/new-user",    scopes: ["openid", "email", "profile", "offline_access"],    requestSignUp: true,});
PropDescriptionType
email?
로그인할 이메일 주소. 로그인할 발급자를 식별하는 데 사용됩니다. 발급자가 제공된 경우 선택 사항입니다.
string
organizationSlug?
로그인할 조직의 슬러그.
string
providerId?
로그인할 제공자의 ID. 이메일이나 발급자 대신 제공할 수 있습니다.
string
domain?
제공자의 도메인.
string
callbackURL
로그인 후 리디렉션할 URL.
string
errorCallbackURL?
로그인 후 리디렉션할 URL.
string
newUserCallbackURL?
사용자가 신규인 경우 로그인 후 리디렉션할 URL.
string
scopes?
제공자에게 요청할 범위.
string[]
requestSignUp?
명시적으로 회원가입을 요청합니다. 이 제공자에 대해 disableImplicitSignUp이 true인 경우 유용합니다.
boolean

사용자가 인증되면 사용자가 존재하지 않는 경우 provisionUser 함수를 사용하여 사용자가 프로비저닝됩니다. 조직 프로비저닝이 활성화되고 제공자가 조직과 연결된 경우 사용자가 조직에 추가됩니다.

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async (user) => {
                // 사용자 프로비저닝
            },
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async (user) => {
                    // 필요한 경우 역할 가져오기
                },
            },
        }),
    ],
});

프로비저닝

SSO 플러그인은 사용자가 SSO 제공자를 통해 로그인할 때 자동으로 사용자를 설정하고 조직 멤버십을 관리하는 강력한 프로비저닝 기능을 제공합니다.

사용자 프로비저닝

사용자 프로비저닝을 사용하면 사용자가 SSO 제공자를 통해 로그인할 때마다 사용자 지정 로직을 실행할 수 있습니다. 다음과 같은 경우에 유용합니다:

  • SSO 제공자의 추가 데이터로 사용자 프로필 설정
  • 외부 시스템과 사용자 속성 동기화
  • 사용자별 리소스 생성
  • SSO 로그인 기록
  • SSO 제공자의 사용자 정보 업데이트
auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            provisionUser: async ({ user, userInfo, token, provider }) => {
                // SSO 데이터로 사용자 프로필 업데이트
                await updateUserProfile(user.id, {
                    department: userInfo.attributes?.department,
                    jobTitle: userInfo.attributes?.jobTitle,
                    manager: userInfo.attributes?.manager,
                    lastSSOLogin: new Date(),
                });

                // 사용자별 리소스 생성
                await createUserWorkspace(user.id);

                // 외부 시스템과 동기화
                await syncUserWithCRM(user.id, userInfo);

                // SSO 로그인 기록
                await auditLog.create({
                    userId: user.id,
                    action: 'sso_signin',
                    provider: provider.providerId,
                    metadata: {
                        email: userInfo.email,
                        ssoProvider: provider.issuer,
                    },
                });
            },
        }),
    ],
});

provisionUser 함수는 다음을 받습니다:

  • user: 데이터베이스의 사용자 객체
  • userInfo: SSO 제공자의 사용자 정보 (속성, 이메일, 이름 등 포함)
  • token: OAuth2 토큰 (OIDC 제공자의 경우) - SAML의 경우 undefined일 수 있음
  • provider: SSO 제공자 구성

조직 프로비저닝

조직 프로비저닝은 SSO 제공자가 특정 조직에 연결된 경우 조직의 사용자 멤버십을 자동으로 관리합니다. 다음과 같은 경우에 특히 유용합니다:

  • 각 회사/도메인이 조직에 매핑되는 엔터프라이즈 SSO
  • SSO 속성을 기반으로 한 자동 역할 할당
  • SSO를 통한 팀 멤버십 관리

기본 조직 프로비저닝

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,           // 조직 프로비저닝 활성화
                defaultRole: "member",     // 신규 멤버의 기본 역할
            },
        }),
    ],
});

사용자 지정 역할을 사용한 고급 조직 프로비저닝

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            organizationProvisioning: {
                disabled: false,
                defaultRole: "member",
                getRole: async ({ user, userInfo, provider }) => {
                    // SSO 속성을 기반으로 역할 할당
                    const department = userInfo.attributes?.department;
                    const jobTitle = userInfo.attributes?.jobTitle;

                    // 직책을 기반으로 한 관리자
                    if (jobTitle?.toLowerCase().includes('manager') ||
                        jobTitle?.toLowerCase().includes('director') ||
                        jobTitle?.toLowerCase().includes('vp')) {
                        return "admin";
                    }

                    // IT 부서를 위한 특별 역할
                    if (department?.toLowerCase() === 'it') {
                        return "admin";
                    }

                    // 나머지는 기본적으로 멤버
                    return "member";
                },
            },
        }),
    ],
});

SSO 제공자를 조직에 연결

SSO 제공자를 등록할 때 특정 조직에 연결할 수 있습니다:

register-org-provider.ts
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp-saml",
        issuer: "https://acme-corp.okta.com",
        domain: "acmecorp.com",
        organizationId: "org_acme_corp_id", // 조직에 연결
        samlConfig: {
            // SAML 구성...
        },
    },
    headers,
});

이제 acmecorp.com의 사용자가 이 제공자를 통해 로그인하면 적절한 역할로 "Acme Corp" 조직에 자동으로 추가됩니다.

다중 조직 예제

다양한 조직에 대해 여러 SSO 제공자를 설정할 수 있습니다:

multi-org-setup.ts
// Acme Corp SAML 제공자
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-corp",
        issuer: "https://acme.okta.com",
        domain: "acmecorp.com",
        organizationId: "org_acme_id",
        samlConfig: { /* ... */ },
    },
    headers,
});

// TechStart OIDC 제공자
await auth.api.registerSSOProvider({
    body: {
        providerId: "techstart-google",
        issuer: "https://accounts.google.com",
        domain: "techstart.io",
        organizationId: "org_techstart_id",
        oidcConfig: { /* ... */ },
    },
    headers,
});

조직 프로비저닝 흐름

  1. 사용자가 로그인 - 조직에 연결된 SSO 제공자를 통해
  2. 사용자 인증 - 데이터베이스에서 사용자를 찾거나 생성
  3. 조직 멤버십 확인 - 사용자가 연결된 조직의 멤버가 아닌 경우
  4. 역할 결정 - defaultRole 또는 getRole 함수 사용
  5. 사용자 추가 - 결정된 역할로 조직에 사용자 추가
  6. 사용자 프로비저닝 실행 - (구성된 경우) 추가 설정

프로비저닝 모범 사례

1. 멱등성 작업

프로비저닝 함수를 여러 번 안전하게 실행할 수 있도록 하세요:

provisionUser: async ({ user, userInfo }) => {
    // 이미 프로비저닝되었는지 확인
    const existingProfile = await getUserProfile(user.id);
    if (!existingProfile.ssoProvisioned) {
        await createUserResources(user.id);
        await markAsProvisioned(user.id);
    }

    // 항상 속성 업데이트 (변경될 수 있음)
    await updateUserAttributes(user.id, userInfo.attributes);
},

2. 오류 처리

사용자 로그인을 차단하지 않도록 오류를 우아하게 처리하세요:

provisionUser: async ({ user, userInfo }) => {
    try {
        await syncWithExternalSystem(user, userInfo);
    } catch (error) {
        // 오류를 기록하지만 throw하지 않음 - 사용자는 여전히 로그인할 수 있음
        console.error('외부 시스템과 사용자 동기화 실패:', error);
        await logProvisioningError(user.id, error);
    }
},

3. 조건부 프로비저닝

필요한 경우에만 특정 프로비저닝 단계를 실행하세요:

organizationProvisioning: {
    disabled: false,
    getRole: async ({ user, userInfo, provider }) => {
        // 특정 제공자에 대해서만 역할 할당 처리
        if (provider.providerId.includes('enterprise')) {
            return determineEnterpriseRole(userInfo);
        }
        return "member";
    },
},

SAML 구성

기본 SSO 제공자

auth.ts
const auth = betterAuth({
    plugins: [
        sso({
            defaultSSO: {
                providerId: "default-saml", // 기본 제공자의 제공자 ID
                samlConfig: {
                    issuer: "https://your-app.com",
                    entryPoint: "https://idp.example.com/sso",
                    cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
                    callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs",
                    spMetadata: {
                        entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
                        metadata: "<!-- Your SP Metadata XML -->",
                    }
                }
            }
        })
    ]
});

defaultSSO 제공자는 다음과 같은 경우에 사용됩니다:

  1. 데이터베이스에서 일치하는 제공자를 찾을 수 없는 경우

이를 통해 데이터베이스에 제공자를 설정하지 않고도 SAML 인증을 테스트할 수 있습니다. defaultSSO 제공자는 일반 SAML 제공자와 동일한 모든 구성 옵션을 지원합니다.

Service Provider 구성

SAML 제공자를 등록할 때 Service Provider (SP) 메타데이터 구성을 제공해야 합니다:

  • metadata: Service Provider를 위한 XML 메타데이터
  • binding: 바인딩 방법, 일반적으로 "post" 또는 "redirect"
  • privateKey: 서명을 위한 개인 키 (선택 사항)
  • privateKeyPass: 개인 키의 비밀번호 (암호화된 경우)
  • isAssertionEncrypted: 어설션을 암호화할지 여부
  • encPrivateKey: 복호화를 위한 개인 키 (암호화가 활성화된 경우)
  • encPrivateKeyPass: 암호화 개인 키의 비밀번호

Identity Provider 구성

Identity Provider (IdP) 구성도 제공해야 합니다:

  • metadata: Identity Provider의 XML 메타데이터
  • privateKey: IdP 통신을 위한 개인 키 (선택 사항)
  • privateKeyPass: IdP 개인 키의 비밀번호 (암호화된 경우)
  • isAssertionEncrypted: IdP의 어설션이 암호화되었는지 여부
  • encPrivateKey: IdP 어설션 복호화를 위한 개인 키
  • encPrivateKeyPass: IdP 복호화 키의 비밀번호

SAML 속성 매핑

SAML 속성이 사용자 필드에 매핑되는 방식을 구성하세요:

mapping: {
    id: "nameID",           // 기본값: "nameID"
    email: "email",         // 기본값: "email" 또는 "nameID"
    name: "displayName",    // 기본값: "displayName"
    firstName: "givenName", // 기본값: "givenName"
    lastName: "surname",    // 기본값: "surname"
    extraFields: {
        department: "department",
        role: "jobTitle",
        phone: "telephoneNumber"
    }
}

SAML 엔드포인트

플러그인은 다음 SAML 엔드포인트를 자동으로 생성합니다:

  • SP Metadata: /api/auth/sso/saml2/sp/metadata?providerId={providerId}
  • SAML Callback: /api/auth/sso/saml2/callback/{providerId}

스키마

플러그인은 제공자의 구성을 저장하기 위해 ssoProvider 테이블에 추가 필드가 필요합니다.

Field NameTypeKeyDescription
idstring데이터베이스 식별자
issuerstring-발급자 식별자
domainstring-제공자의 도메인
oidcConfigstring-OIDC 구성 (JSON 문자열)
samlConfigstring-SAML 구성 (JSON 문자열)
userIdstring-사용자 ID
providerIdstring-제공자 ID. 제공자를 식별하고 리디렉션 URL을 생성하는 데 사용됩니다.
organizationIdstring-조직 ID. 제공자가 조직에 연결된 경우.

Okta를 사용한 SAML SSO 설정 및 DummyIDP를 사용한 테스트에 대한 예제를 포함한 자세한 가이드는 SAML SSO with Okta를 참조하세요.

옵션

서버

provisionUser: 사용자가 SSO 제공자로 로그인할 때 사용자를 프로비저닝하는 사용자 지정 함수.

organizationProvisioning: 조직에 사용자를 프로비저닝하기 위한 옵션.

defaultOverrideUserInfo: 기본적으로 제공자 정보로 사용자 정보를 재정의.

disableImplicitSignUp: 신규 사용자에 대한 암묵적 가입 비활성화.

trustEmailVerified: 제공자의 이메일 확인 플래그 신뢰.

Prop

Type