mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-06 12:27:44 +00:00
feat(sso): defaultSSO options and ACS endpoint (#3660)
Co-authored-by: Bereket Engida <Bekacru@gmail.com> Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a208c09894
commit
b3ead859e6
@@ -21,6 +21,7 @@ import { createPool } from "mysql2/promise";
|
|||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import { passkey } from "better-auth/plugins/passkey";
|
import { passkey } from "better-auth/plugins/passkey";
|
||||||
import { stripe } from "@better-auth/stripe";
|
import { stripe } from "@better-auth/stripe";
|
||||||
|
import { sso } from "@better-auth/sso";
|
||||||
import { Stripe } from "stripe";
|
import { Stripe } from "stripe";
|
||||||
|
|
||||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||||
@@ -182,7 +183,6 @@ export const auth = betterAuth({
|
|||||||
multiSession(),
|
multiSession(),
|
||||||
oAuthProxy(),
|
oAuthProxy(),
|
||||||
nextCookies(),
|
nextCookies(),
|
||||||
|
|
||||||
oneTap(),
|
oneTap(),
|
||||||
customSession(async (session) => {
|
customSession(async (session) => {
|
||||||
return {
|
return {
|
||||||
@@ -238,6 +238,111 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
sso({
|
||||||
|
defaultSSO: [
|
||||||
|
{
|
||||||
|
domain: "http://localhost:3000",
|
||||||
|
providerId: "sso",
|
||||||
|
samlConfig: {
|
||||||
|
issuer: "http://localhost:3001/api/sso/saml2/sp/metadata",
|
||||||
|
entryPoint: "http://localhost:3001/api/sso/saml2/sp/acs",
|
||||||
|
cert: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx
|
||||||
|
MjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD
|
||||||
|
ggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ
|
||||||
|
HsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj
|
||||||
|
yfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g
|
||||||
|
0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7
|
||||||
|
mJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO
|
||||||
|
aDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE
|
||||||
|
FD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm
|
||||||
|
RNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z
|
||||||
|
FIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm
|
||||||
|
2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx
|
||||||
|
UUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J
|
||||||
|
QkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9
|
||||||
|
vhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+
|
||||||
|
jzGhYL6m9gFTm/8=
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
spMetadata: {
|
||||||
|
metadata: `
|
||||||
|
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3000/api/sso/saml2/sp/metadata">
|
||||||
|
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||||
|
<md:KeyDescriptor use="signing">
|
||||||
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:X509Data>
|
||||||
|
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
|
||||||
|
</ds:X509Data>
|
||||||
|
</ds:KeyInfo>
|
||||||
|
</md:KeyDescriptor>
|
||||||
|
<md:KeyDescriptor use="encryption">
|
||||||
|
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||||
|
<ds:X509Data>
|
||||||
|
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
|
||||||
|
</ds:X509Data>
|
||||||
|
</ds:KeyInfo>
|
||||||
|
</md:KeyDescriptor>
|
||||||
|
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/api/sso/saml2/sp/sls"/>
|
||||||
|
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||||
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3000/api/sso/saml2/sp/acs" index="1"/>
|
||||||
|
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3000/api/sso/saml2/sp/acs" index="1"/>
|
||||||
|
</md:SPSSODescriptor>
|
||||||
|
<md:Organization>
|
||||||
|
<md:OrganizationName xml:lang="en-US">Organization Name</md:OrganizationName>
|
||||||
|
<md:OrganizationDisplayName xml:lang="en-US">Organization DisplayName</md:OrganizationDisplayName>
|
||||||
|
<md:OrganizationURL xml:lang="en-US">http://localhost:3000/</md:OrganizationURL>
|
||||||
|
</md:Organization>
|
||||||
|
<md:ContactPerson contactType="technical">
|
||||||
|
<md:GivenName>Technical Contact Name</md:GivenName>
|
||||||
|
<md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
|
||||||
|
</md:ContactPerson>
|
||||||
|
<md:ContactPerson contactType="support">
|
||||||
|
<md:GivenName>Support Contact Name</md:GivenName>
|
||||||
|
<md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
|
||||||
|
</md:ContactPerson>
|
||||||
|
</md:EntityDescriptor>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
idpMetadata: {
|
||||||
|
entityURL:
|
||||||
|
"https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/metadata",
|
||||||
|
entityID:
|
||||||
|
"https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/metadata",
|
||||||
|
redirectURL:
|
||||||
|
"https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/sso",
|
||||||
|
singleSignOnService: [
|
||||||
|
{
|
||||||
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||||
|
Location:
|
||||||
|
"https://dummyidp.com/apps/app_01k16v4vb5yytywqjjvv2b3435/sso",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cert: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDBzCCAe+gAwIBAgIUCLBK4f75EXEe4gyroYnVaqLoSp4wDQYJKoZIhvcNAQEL
|
||||||
|
BQAwEzERMA8GA1UEAwwIZHVtbXlpZHAwHhcNMjQwNTEzMjE1NDE2WhcNMzQwNTEx
|
||||||
|
MjE1NDE2WjATMREwDwYDVQQDDAhkdW1teWlkcDCCASIwDQYJKoZIhvcNAQEBBQAD
|
||||||
|
ggEPADCCAQoCggEBAKhmgQmWb8NvGhz952XY4SlJlpWIK72RilhOZS9frDYhqWVJ
|
||||||
|
HsGH9Z7sSzrM/0+YvCyEWuZV9gpMeIaHZxEPDqW3RJ7KG51fn/s/qFvwctf+CZDj
|
||||||
|
yfGDzYs+XIgf7p56U48EmYeWpB/aUW64gSbnPqrtWmVFBisOfIx5aY3NubtTsn+g
|
||||||
|
0XbdX0L57+NgSvPQHXh/GPXA7xCIWm54G5kqjozxbKEFA0DS3yb6oHRQWHqIAM/7
|
||||||
|
mJMdUVZNIV1q7c2JIgAl23uDWq+2KTE2R5liP/KjvjwKonVKtTqGqX6ei25rsTHO
|
||||||
|
aDpBH/LdQK2txgsm7R7+IThWNvUI0TttrmwBqyMCAwEAAaNTMFEwHQYDVR0OBBYE
|
||||||
|
FD142gxIAJMhpgMkgpzmRNoW9XbEMB8GA1UdIwQYMBaAFD142gxIAJMhpgMkgpzm
|
||||||
|
RNoW9XbEMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADQd6k6z
|
||||||
|
FIc20GfGHY5C2MFwyGOmP5/UG/JiTq7Zky28G6D0NA0je+GztzXx7VYDfCfHxLcm
|
||||||
|
2k5t9nYhb9kVawiLUUDVF6s+yZUXA4gUA3KoTWh1/oRxR3ggW7dKYm9fsNOdQAbx
|
||||||
|
UUkzp7HLZ45ZlpKUS0hO7es+fPyF5KVw0g0SrtQWwWucnQMAQE9m+B0aOf+92y7J
|
||||||
|
QkdgdR8Gd/XZ4NZfoOnKV7A1utT4rWxYCgICeRTHx9tly5OhPW4hQr5qOpngcsJ9
|
||||||
|
vhr86IjznQXhfj3hql5lA3VbHW04ro37ROIkh2bShDq5dwJJHpYCGrF3MQv8S3m+
|
||||||
|
jzGhYL6m9gFTm/8=
|
||||||
|
-----END CERTIFICATE-----`,
|
||||||
|
},
|
||||||
|
callbackUrl: "/dashboard",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
deviceAuthorization({
|
deviceAuthorization({
|
||||||
expiresIn: "3min",
|
expiresIn: "3min",
|
||||||
interval: "5s",
|
interval: "5s",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/stripe": "workspace:*",
|
"@better-auth/stripe": "workspace:*",
|
||||||
|
"@better-auth/sso": "workspace:*",
|
||||||
"@better-fetch/fetch": "catalog:",
|
"@better-fetch/fetch": "catalog:",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
"@libsql/client": "^0.15.14",
|
"@libsql/client": "^0.15.14",
|
||||||
|
|||||||
@@ -2166,6 +2166,21 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "SAML SSO with Okta",
|
||||||
|
href: "/docs/guides/saml-sso-with-okta",
|
||||||
|
icon: () => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="m140.844 1.778l-5.266 64.853a66 66 0 0 0-7.542-.427c-3.203 0-6.334.214-9.393.712l-2.99-31.432a1.72 1.72 0 0 1 1.709-1.848h5.337l-2.562-31.787C120.066.853 120.848 0 121.774 0h17.434c.996 0 1.779.853 1.636 1.849zm-43.976 3.2c-.285-.925-1.281-1.494-2.206-1.138L78.295 9.813c-.925.356-1.352 1.423-.925 2.276l13.307 29.013l-5.052 1.85c-.926.355-1.352 1.421-.926 2.275l13.592 28.515a61 61 0 0 1 15.868-6.044L96.94 4.978zM56.734 23.04l37.643 53.049c-4.768 3.129-9.108 6.827-12.809 11.093L59.011 64.996a1.72 1.72 0 0 1 .071-2.49l4.127-3.413L40.794 36.41c-.711-.711-.64-1.849.142-2.489l13.307-11.164c.783-.64 1.85-.498 2.42.284zM25.139 53.76c-.783-.569-1.921-.284-2.42.569l-8.68 15.075c-.499.854-.143 1.92.71 2.347L43.64 85.404l-2.704 4.623c-.498.853-.142 1.99.783 2.346l28.749 13.156a60.2 60.2 0 0 1 8.254-14.791zM3.862 94.72c.143-.996 1.139-1.564 2.064-1.351l62.976 16.427a62.3 62.3 0 0 0-2.704 16.782l-31.524-2.56a1.642 1.642 0 0 1-1.494-1.991l.925-5.263l-31.808-2.986c-.996-.071-1.637-.996-1.495-1.991l2.99-17.138zm-2.348 42.524c-.996.072-1.637.996-1.494 1.992l3.06 17.137c.142.996 1.138 1.565 2.063 1.351l30.883-8.035l.925 5.262c.143.996 1.139 1.565 2.064 1.351l30.456-8.39c-1.779-5.263-2.917-10.88-3.202-16.64l-64.826 5.972zM11.62 182.33c-.498-.853-.143-1.92.711-2.347l58.778-27.875c2.206 5.262 5.195 10.169 8.753 14.577L54.1 185.031c-.783.569-1.921.356-2.42-.498l-2.704-4.693l-26.257 18.133c-.783.57-1.922.285-2.42-.569l-8.752-15.075zm71.23-12.231L37.094 216.39c-.712.711-.64 1.849.142 2.489l13.378 11.164c.783.64 1.85.498 2.42-.284l18.501-26.027l4.127 3.485c.783.64 1.922.498 2.49-.356l17.933-26.026c-4.839-2.987-9.322-6.614-13.165-10.738zm-9.037 74.31c-.925-.355-1.352-1.421-.925-2.275L100 182.97c4.98 2.56 10.389 4.48 16.01 5.547l-7.97 30.577c-.213.925-1.28 1.494-2.205 1.138l-5.052-1.849l-8.468 30.791c-.285.925-1.281 1.494-2.206 1.138l-16.367-5.973zm46.68-55.11l-5.265 64.853c-.071.996.711 1.849 1.637 1.849h17.434c.996 0 1.779-.853 1.636-1.849l-2.561-31.787h5.336a1.72 1.72 0 0 0 1.708-1.848l-2.988-31.432c-3.06.498-6.191.712-9.393.712c-2.562 0-5.053-.143-7.543-.498m62.763-175.574c.427-.924 0-1.92-.925-2.275l-16.366-5.973c-.926-.356-1.922.213-2.206 1.137l-8.468 30.791l-5.053-1.848c-.925-.356-1.921.213-2.206 1.137l-7.97 30.578c5.693 1.138 11.03 3.058 16.011 5.547zm35.722 25.814L173.222 85.83a62 62 0 0 0-13.165-10.738l17.933-26.026c.569-.783 1.707-.996 2.49-.356l4.127 3.485l18.502-26.027c.57-.782 1.708-.925 2.42-.285l13.377 11.165c.783.64.783 1.778.143 2.489zm24.764 36.409c.925-.427 1.21-1.494.711-2.347L235.7 58.524c-.498-.853-1.637-1.066-2.42-.568l-26.257 18.133l-2.704-4.622c-.499-.854-1.637-1.138-2.42-.498l-25.76 18.347c3.558 4.408 6.476 9.315 8.753 14.577l58.778-27.875zm9.25 23.609l2.99 17.137c.142.996-.499 1.85-1.495 1.991l-64.826 6.045c-.285-5.831-1.424-11.378-3.203-16.64l30.457-8.391c.925-.285 1.921.355 2.063 1.35l.925 5.263l30.884-8.035c.925-.214 1.92.355 2.063 1.35zm-2.917 62.933c.925.213 1.921-.356 2.064-1.351L255.126 144c.143-.996-.498-1.849-1.494-1.991l-31.808-2.987l.925-5.262c.142-.996-.498-1.849-1.495-1.991l-31.523-2.56a62.3 62.3 0 0 1-2.704 16.782l62.976 16.427zM233.28 201.6c-.498.853-1.636 1.067-2.419.569l-53.583-36.978a60.2 60.2 0 0 0 8.254-14.791l28.749 13.156c.925.426 1.28 1.493.783 2.346l-2.704 4.622l28.89 13.654c.854.426 1.21 1.493.712 2.346zm-71.657-21.831l37.643 53.049c.57.782 1.708.924 2.42.284l13.306-11.164c.783-.64.783-1.778.143-2.49l-22.415-22.684l4.127-3.413c.783-.64.783-1.778.07-2.489l-22.557-22.186c-3.771 4.266-8.04 8.035-12.808 11.093zm-.356 72.249c-.925.355-1.921-.214-2.206-1.138l-17.22-62.72a61 61 0 0 0 15.868-6.044l13.592 28.515c.426.925 0 1.991-.926 2.276l-5.052 1.849l13.307 29.013c.427.924 0 1.92-.925 2.275l-16.367 5.974z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Optimize for Performance",
|
title: "Optimize for Performance",
|
||||||
href: "/docs/guides/optimizing-for-performance",
|
href: "/docs/guides/optimizing-for-performance",
|
||||||
|
|||||||
174
docs/content/docs/guides/saml-sso-with-okta.mdx
Normal file
174
docs/content/docs/guides/saml-sso-with-okta.mdx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
title: SAML SSO with Okta
|
||||||
|
description: A guide to integrating SAML Single Sign-On (SSO) with Better Auth, featuring Okta
|
||||||
|
---
|
||||||
|
|
||||||
|
This guide walks you through setting up SAML Single Sign-On (SSO) with your Identity Provider (IdP), using Okta as an example. For advanced configuration details and the full API reference, check out the [SSO Plugin Documentation](/docs/plugins/sso).
|
||||||
|
|
||||||
|
## What is SAML?
|
||||||
|
|
||||||
|
SAML (Security Assertion Markup Language) is an XML-based standard for exchanging authentication and authorization data between an Identity Provider (IdP) (e.g., Okta, Azure AD, OneLogin) and a Service Provider (SP) (in this case, Better Auth).
|
||||||
|
|
||||||
|
In this setup:
|
||||||
|
|
||||||
|
- **IdP (Okta)**: Authenticates users and sends assertions about their identity.
|
||||||
|
- **SP (Better Auth)**: Validates assertions and logs the user in.up.
|
||||||
|
|
||||||
|
### Step 1: Create a SAML Application in Okta
|
||||||
|
|
||||||
|
1. Log in to your Okta Admin Console
|
||||||
|
2. Navigate to Applications > Applications
|
||||||
|
3. Click "Create App Integration"
|
||||||
|
4. Select "SAML 2.0" as the Sign-in method
|
||||||
|
5. Configure the following settings:
|
||||||
|
|
||||||
|
- **Single Sign-on URL**: Your Better Auth ACS endpoint (e.g., `http://localhost:3000/api/auth/sso/saml2/sp/acs/sso`). while `sso` being your providerId
|
||||||
|
- **Audience URI (SP Entity ID)**: Your Better Auth metadata URL (e.g., `http://localhost:3000/api/auth/sso/saml2/sp/metadata`)
|
||||||
|
- **Name ID format**: Email Address or any of your choice.
|
||||||
|
|
||||||
|
6. Download the IdP metadata XML file and certificate
|
||||||
|
|
||||||
|
### Step 2: Configure Better Auth
|
||||||
|
|
||||||
|
Here’s an example configuration for Okta in a dev environment:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ssoConfig = {
|
||||||
|
defaultSSO: [{
|
||||||
|
domain: "localhost:3000", // Your domain
|
||||||
|
providerId: "sso",
|
||||||
|
samlConfig: {
|
||||||
|
// SP Configuration
|
||||||
|
issuer: "http://localhost:3000/api/auth/sso/saml2/sp/metadata",
|
||||||
|
entryPoint: "https://trial-1076874.okta.com/app/trial-1076874_samltest_1/exktofb0a62hqLAUL697/sso/saml",
|
||||||
|
callbackUrl: "/dashboard", // Redirect after successful authentication
|
||||||
|
|
||||||
|
// IdP Configuration
|
||||||
|
idpMetadata: {
|
||||||
|
entityID: "https://trial-1076874.okta.com/app/exktofb0a62hqLAUL697/sso/saml/metadata",
|
||||||
|
singleSignOnService: [{
|
||||||
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||||
|
Location: "https://trial-1076874.okta.com/app/trial-1076874_samltest_1/exktofb0a62hqLAUL697/sso/saml"
|
||||||
|
}],
|
||||||
|
cert: `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDqjCCApKgAwIBAgIGAZhVGMeUMA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG
|
||||||
|
...
|
||||||
|
[Your Okta Certificate]
|
||||||
|
...
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
},
|
||||||
|
|
||||||
|
// SP Metadata
|
||||||
|
spMetadata: {
|
||||||
|
metadata: `<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
||||||
|
entityID="http://localhost:3000/api/sso/saml2/sp/metadata">
|
||||||
|
...
|
||||||
|
[Your SP Metadata XML]
|
||||||
|
...
|
||||||
|
</md:EntityDescriptor>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Multiple Default Providers (Optional)
|
||||||
|
|
||||||
|
You can configure multiple SAML providers for different domains:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ssoConfig = {
|
||||||
|
defaultSSO: [
|
||||||
|
{
|
||||||
|
domain: "company.com",
|
||||||
|
providerId: "company-okta",
|
||||||
|
samlConfig: {
|
||||||
|
// Okta SAML configuration for company.com
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "partner.com",
|
||||||
|
providerId: "partner-adfs",
|
||||||
|
samlConfig: {
|
||||||
|
// ADFS SAML configuration for partner.com
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
domain: "contractor.org",
|
||||||
|
providerId: "contractor-azure",
|
||||||
|
samlConfig: {
|
||||||
|
// Azure AD SAML configuration for contractor.org
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
**Explicit**: Pass providerId directly when signing in.
|
||||||
|
**Domain fallback:** Matches based on the user’s email domain. e.g. user@company.com → matches `company-okta` provider.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
|
||||||
|
### Step 4: Initiating Sign-In
|
||||||
|
|
||||||
|
You can start an SSO flow in three ways:
|
||||||
|
|
||||||
|
**1. Explicitly by `providerId` (recommended):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Explicitly specify which provider to use
|
||||||
|
await authClient.signIn.sso({
|
||||||
|
providerId: "company-okta",
|
||||||
|
callbackURL: "/dashboard"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. By email domain matching:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Automatically matches provider based on email domain
|
||||||
|
await authClient.signIn.sso({
|
||||||
|
email: "user@company.com",
|
||||||
|
callbackURL: "/dashboard"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. By specifying domain:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Explicitly specify domain for matching
|
||||||
|
await authClient.signIn.sso({
|
||||||
|
domain: "partner.com",
|
||||||
|
callbackURL: "/dashboard"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes**:
|
||||||
|
- DummyIDP should ONLY be used for development and testing
|
||||||
|
- Never use these certificates in production
|
||||||
|
- The example uses `localhost:3000` - adjust URLs for your environment
|
||||||
|
- For production, always use proper IdP providers like Okta, Azure AD, or OneLogin
|
||||||
|
|
||||||
|
### Step 5: Dynamically Registering SAML Providers
|
||||||
|
|
||||||
|
For dynamic registration, you should register SAML providers using the API. See the [SSO Plugin Documentation](/docs/plugins/sso#register-a-saml-provider) for detailed registration instructions.
|
||||||
|
|
||||||
|
Example registration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await authClient.sso.register({
|
||||||
|
providerId: "okta-prod",
|
||||||
|
issuer: "https://your-domain.com",
|
||||||
|
domain: "your-domain.com",
|
||||||
|
samlConfig: {
|
||||||
|
// Your production SAML configuration
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [SSO Plugin Documentation](/docs/plugins/sso)
|
||||||
|
- [Okta SAML Documentation](https://developer.okta.com/docs/concepts/saml/)
|
||||||
|
- [SAML 2.0 Specification](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf)
|
||||||
@@ -7,11 +7,6 @@ description: Integrate Single Sign-On (SSO) with your application.
|
|||||||
|
|
||||||
Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC), OAuth2 providers, and SAML 2.0.
|
Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC), OAuth2 providers, and SAML 2.0.
|
||||||
|
|
||||||
|
|
||||||
<Callout type="warn">
|
|
||||||
This plugin is in active development and may not be suitable for production use. Please report any issues or bugs on [GitHub](https://github.com/better-auth/better-auth) and any security concerns on [security@better-auth.com](mailto:security@better-auth.com).
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
@@ -100,14 +95,18 @@ await authClient.sso.register({
|
|||||||
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
|
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
|
||||||
scopes: ["openid", "email", "profile"],
|
scopes: ["openid", "email", "profile"],
|
||||||
pkce: true,
|
pkce: true,
|
||||||
},
|
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
emailVerified: "email_verified",
|
emailVerified: "email_verified",
|
||||||
name: "name",
|
name: "name",
|
||||||
image: "picture",
|
image: "picture",
|
||||||
},
|
extraFields: {
|
||||||
|
department: "department",
|
||||||
|
role: "role"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -129,14 +128,18 @@ await auth.api.registerSSOProvider({
|
|||||||
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
|
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
|
||||||
scopes: ["openid", "email", "profile"],
|
scopes: ["openid", "email", "profile"],
|
||||||
pkce: true,
|
pkce: true,
|
||||||
},
|
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
emailVerified: "email_verified",
|
emailVerified: "email_verified",
|
||||||
name: "name",
|
name: "name",
|
||||||
image: "picture",
|
image: "picture",
|
||||||
},
|
extraFields: {
|
||||||
|
department: "department",
|
||||||
|
role: "role"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -183,7 +186,6 @@ await authClient.sso.register({
|
|||||||
isAssertionEncrypted: true,
|
isAssertionEncrypted: true,
|
||||||
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
|
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
|
||||||
encPrivateKeyPass: "your-sp-encryption-key-password"
|
encPrivateKeyPass: "your-sp-encryption-key-password"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "nameID",
|
id: "nameID",
|
||||||
@@ -191,11 +193,13 @@ await authClient.sso.register({
|
|||||||
name: "displayName",
|
name: "displayName",
|
||||||
firstName: "givenName",
|
firstName: "givenName",
|
||||||
lastName: "surname",
|
lastName: "surname",
|
||||||
|
emailVerified: "email_verified",
|
||||||
extraFields: {
|
extraFields: {
|
||||||
department: "department",
|
department: "department",
|
||||||
role: "role"
|
role: "role"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -233,7 +237,6 @@ await auth.api.registerSSOProvider({
|
|||||||
isAssertionEncrypted: true,
|
isAssertionEncrypted: true,
|
||||||
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
|
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
|
||||||
encPrivateKeyPass: "your-sp-encryption-key-password"
|
encPrivateKeyPass: "your-sp-encryption-key-password"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "nameID",
|
id: "nameID",
|
||||||
@@ -241,11 +244,13 @@ await auth.api.registerSSOProvider({
|
|||||||
name: "displayName",
|
name: "displayName",
|
||||||
firstName: "givenName",
|
firstName: "givenName",
|
||||||
lastName: "surname",
|
lastName: "surname",
|
||||||
|
emailVerified: "email_verified",
|
||||||
extraFields: {
|
extraFields: {
|
||||||
department: "department",
|
department: "department",
|
||||||
role: "role"
|
role: "role"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -611,6 +616,36 @@ organizationProvisioning: {
|
|||||||
|
|
||||||
## SAML Configuration
|
## SAML Configuration
|
||||||
|
|
||||||
|
|
||||||
|
### Default SSO Provider
|
||||||
|
|
||||||
|
```ts title="auth.ts"
|
||||||
|
const auth = betterAuth({
|
||||||
|
plugins: [
|
||||||
|
sso({
|
||||||
|
defaultSSO: {
|
||||||
|
providerId: "default-saml", // Provider ID for the default provider
|
||||||
|
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 -->",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The defaultSSO provider will be used when:
|
||||||
|
1. No matching provider is found in the database
|
||||||
|
|
||||||
|
This allows you to test SAML authentication without setting up providers in the database. The defaultSSO provider supports all the same configuration options as regular SAML providers.
|
||||||
|
|
||||||
### Service Provider Configuration
|
### Service Provider Configuration
|
||||||
|
|
||||||
When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration:
|
When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration:
|
||||||
@@ -679,6 +714,8 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
For a detailed guide on setting up SAML SSO with examples for Okta and testing with DummyIDP, see our [SAML SSO Setup Guide](/docs/guides/sso-saml-guide).
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
@@ -735,5 +772,31 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr
|
|||||||
type: "number | function",
|
type: "number | function",
|
||||||
default: 10,
|
default: 10,
|
||||||
},
|
},
|
||||||
|
defaultSSO: {
|
||||||
|
description: "Configure a default SSO provider for testing and development. This provider will be used when no matching provider is found in the database.",
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
domain: {
|
||||||
|
description: "The domain to match for this default provider.",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
providerId: {
|
||||||
|
description: "The provider ID to use for the default provider.",
|
||||||
|
type: "string",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
samlConfig: {
|
||||||
|
description: "SAML configuration for the default provider.",
|
||||||
|
type: "SAMLConfig",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
oidcConfig: {
|
||||||
|
description: "OIDC configuration for the default provider.",
|
||||||
|
type: "OIDCConfig",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# @better-auth/sso
|
|
||||||
|
|
||||||
## 1.3.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 2bd2fa9: Added support for listing organization members with pagination, sorting, and filtering, and improved client inference for additional organization fields. Also fixed date handling in rate limits and tokens, improved Notion OAuth user extraction, and ensured session is always set in context.
|
|
||||||
|
|
||||||
Organization
|
|
||||||
|
|
||||||
- Added listMembers API with pagination, sorting, and filtering.
|
|
||||||
- Added membersLimit param to getFullOrganization.
|
|
||||||
- Improved client inference for additional fields in organization schemas.
|
|
||||||
- Bug Fixes
|
|
||||||
- Fixed date handling by casting DB values to Date objects before using date methods.
|
|
||||||
- Fixed Notion OAuth to extract user info correctly.
|
|
||||||
- Ensured session is set in context when reading from cookie cach
|
|
||||||
|
|
||||||
- Updated dependencies [2bd2fa9]
|
|
||||||
- better-auth@1.3.4
|
|
||||||
@@ -24,6 +24,7 @@ import { decodeJwt } from "jose";
|
|||||||
import { setSessionCookie } from "better-auth/cookies";
|
import { setSessionCookie } from "better-auth/cookies";
|
||||||
import type { FlowResult } from "samlify/types/src/flow";
|
import type { FlowResult } from "samlify/types/src/flow";
|
||||||
import { XMLValidator } from "fast-xml-parser";
|
import { XMLValidator } from "fast-xml-parser";
|
||||||
|
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
||||||
|
|
||||||
const fastValidator = {
|
const fastValidator = {
|
||||||
async validate(xml: string) {
|
async validate(xml: string) {
|
||||||
@@ -37,6 +38,25 @@ const fastValidator = {
|
|||||||
|
|
||||||
saml.setSchemaValidator(fastValidator);
|
saml.setSchemaValidator(fastValidator);
|
||||||
|
|
||||||
|
export interface OIDCMapping {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: string;
|
||||||
|
name?: string;
|
||||||
|
image?: string;
|
||||||
|
extraFields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SAMLMapping {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: string;
|
||||||
|
name?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
extraFields?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OIDCConfig {
|
export interface OIDCConfig {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
pkce: boolean;
|
pkce: boolean;
|
||||||
@@ -50,30 +70,49 @@ export interface OIDCConfig {
|
|||||||
tokenEndpoint?: string;
|
tokenEndpoint?: string;
|
||||||
tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
|
tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
|
||||||
jwksEndpoint?: string;
|
jwksEndpoint?: string;
|
||||||
mapping?: {
|
mapping?: OIDCMapping;
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
emailVerified?: string;
|
|
||||||
name?: string;
|
|
||||||
image?: string;
|
|
||||||
extraFields?: Record<string, string>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SAMLConfig {
|
export interface SAMLConfig {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
entryPoint: string;
|
entryPoint: string;
|
||||||
signingKey: string;
|
cert: string;
|
||||||
certificate: string;
|
callbackUrl: string;
|
||||||
attributeConsumingServiceIndex: number;
|
audience?: string;
|
||||||
mapping?: {
|
idpMetadata?: {
|
||||||
id?: string;
|
metadata?: string;
|
||||||
email?: string;
|
entityID?: string;
|
||||||
name?: string;
|
entityURL?: string;
|
||||||
firstName?: string;
|
redirectURL?: string;
|
||||||
lastName?: string;
|
cert?: string;
|
||||||
extraFields?: Record<string, string>;
|
privateKey?: string;
|
||||||
|
privateKeyPass?: string;
|
||||||
|
isAssertionEncrypted?: boolean;
|
||||||
|
encPrivateKey?: string;
|
||||||
|
encPrivateKeyPass?: string;
|
||||||
|
singleSignOnService?: Array<{
|
||||||
|
Binding: string;
|
||||||
|
Location: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
spMetadata: {
|
||||||
|
metadata?: string;
|
||||||
|
entityID?: string;
|
||||||
|
binding?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
privateKeyPass?: string;
|
||||||
|
isAssertionEncrypted?: boolean;
|
||||||
|
encPrivateKey?: string;
|
||||||
|
encPrivateKeyPass?: string;
|
||||||
|
};
|
||||||
|
wantAssertionsSigned?: boolean;
|
||||||
|
signatureAlgorithm?: string;
|
||||||
|
digestAlgorithm?: string;
|
||||||
|
identifierFormat?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
decryptionPvk?: string;
|
||||||
|
additionalParams?: Record<string, any>;
|
||||||
|
mapping?: SAMLMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSOProvider {
|
export interface SSOProvider {
|
||||||
@@ -132,6 +171,29 @@ export interface SSOOptions {
|
|||||||
provider: SSOProvider;
|
provider: SSOProvider;
|
||||||
}) => Promise<"member" | "admin">;
|
}) => Promise<"member" | "admin">;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Default SSO provider configurations for testing.
|
||||||
|
* These will take the precedence over the database providers.
|
||||||
|
*/
|
||||||
|
defaultSSO?: Array<{
|
||||||
|
/**
|
||||||
|
* The domain to match for this default provider.
|
||||||
|
* This is only used to match incoming requests to this default provider.
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
/**
|
||||||
|
* The provider ID to use
|
||||||
|
*/
|
||||||
|
providerId: string;
|
||||||
|
/**
|
||||||
|
* SAML configuration
|
||||||
|
*/
|
||||||
|
samlConfig?: SAMLConfig;
|
||||||
|
/**
|
||||||
|
* OIDC configuration
|
||||||
|
*/
|
||||||
|
oidcConfig?: OIDCConfig;
|
||||||
|
}>;
|
||||||
/**
|
/**
|
||||||
* Override user info with the provider info.
|
* Override user info with the provider info.
|
||||||
* @default false
|
* @default false
|
||||||
@@ -284,6 +346,37 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
})
|
})
|
||||||
.default(true)
|
.default(true)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
mapping: z
|
||||||
|
.object({
|
||||||
|
id: z.string({}).meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for user ID (defaults to 'sub')",
|
||||||
|
}),
|
||||||
|
email: z.string({}).meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for email (defaults to 'email')",
|
||||||
|
}),
|
||||||
|
emailVerified: z
|
||||||
|
.string({})
|
||||||
|
.meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for email verification (defaults to 'email_verified')",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
name: z.string({}).meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for name (defaults to 'name')",
|
||||||
|
}),
|
||||||
|
image: z
|
||||||
|
.string({})
|
||||||
|
.meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for image (defaults to 'picture')",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
extraFields: z.record(z.string(), z.any()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
samlConfig: z
|
samlConfig: z
|
||||||
@@ -300,18 +393,35 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
audience: z.string().optional(),
|
audience: z.string().optional(),
|
||||||
idpMetadata: z
|
idpMetadata: z
|
||||||
.object({
|
.object({
|
||||||
metadata: z.string(),
|
metadata: z.string().optional(),
|
||||||
|
entityID: z.string().optional(),
|
||||||
|
cert: z.string().optional(),
|
||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
privateKeyPass: z.string().optional(),
|
privateKeyPass: z.string().optional(),
|
||||||
isAssertionEncrypted: z.boolean().optional(),
|
isAssertionEncrypted: z.boolean().optional(),
|
||||||
encPrivateKey: z.string().optional(),
|
encPrivateKey: z.string().optional(),
|
||||||
encPrivateKeyPass: z.string().optional(),
|
encPrivateKeyPass: z.string().optional(),
|
||||||
|
singleSignOnService: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
Binding: z.string().meta({
|
||||||
|
description: "The binding type for the SSO service",
|
||||||
|
}),
|
||||||
|
Location: z.string().meta({
|
||||||
|
description: "The URL for the SSO service",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.meta({
|
||||||
|
description: "Single Sign-On service configuration",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
spMetadata: z.object({
|
spMetadata: z.object({
|
||||||
metadata: z.string(),
|
metadata: z.string().optional(),
|
||||||
|
entityID: z.string().optional(),
|
||||||
binding: z.string().optional(),
|
binding: z.string().optional(),
|
||||||
|
|
||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
privateKeyPass: z.string().optional(),
|
privateKeyPass: z.string().optional(),
|
||||||
isAssertionEncrypted: z.boolean().optional(),
|
isAssertionEncrypted: z.boolean().optional(),
|
||||||
@@ -325,39 +435,45 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
privateKey: z.string().optional(),
|
privateKey: z.string().optional(),
|
||||||
decryptionPvk: z.string().optional(),
|
decryptionPvk: z.string().optional(),
|
||||||
additionalParams: z.record(z.string(), z.any()).optional(),
|
additionalParams: z.record(z.string(), z.any()).optional(),
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
mapping: z
|
mapping: z
|
||||||
.object({
|
.object({
|
||||||
id: z.string({}).meta({
|
id: z.string({}).meta({
|
||||||
description:
|
description:
|
||||||
"The field in the user info response that contains the id. Defaults to 'sub'",
|
"Field mapping for user ID (defaults to 'nameID')",
|
||||||
}),
|
}),
|
||||||
email: z.string({}).meta({
|
email: z.string({}).meta({
|
||||||
description:
|
description:
|
||||||
"The field in the user info response that contains the email. Defaults to 'email'",
|
"Field mapping for email (defaults to 'email')",
|
||||||
}),
|
}),
|
||||||
emailVerified: z
|
emailVerified: z
|
||||||
.string({})
|
.string({})
|
||||||
.meta({
|
.meta({
|
||||||
description:
|
description: "Field mapping for email verification",
|
||||||
"The field in the user info response that contains whether the email is verified. defaults to 'email_verified'",
|
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
name: z.string({}).meta({
|
name: z.string({}).meta({
|
||||||
description:
|
description:
|
||||||
"The field in the user info response that contains the name. Defaults to 'name'",
|
"Field mapping for name (defaults to 'displayName')",
|
||||||
}),
|
}),
|
||||||
image: z
|
firstName: z
|
||||||
.string({})
|
.string({})
|
||||||
.meta({
|
.meta({
|
||||||
description:
|
description:
|
||||||
"The field in the user info response that contains the image. Defaults to 'picture'",
|
"Field mapping for first name (defaults to 'givenName')",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
lastName: z
|
||||||
|
.string({})
|
||||||
|
.meta({
|
||||||
|
description:
|
||||||
|
"Field mapping for last name (defaults to 'surname')",
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
extraFields: z.record(z.string(), z.any()).optional(),
|
extraFields: z.record(z.string(), z.any()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
organizationId: z
|
organizationId: z
|
||||||
.string({})
|
.string({})
|
||||||
.meta({
|
.meta({
|
||||||
@@ -632,7 +748,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
discoveryEndpoint:
|
discoveryEndpoint:
|
||||||
body.oidcConfig.discoveryEndpoint ||
|
body.oidcConfig.discoveryEndpoint ||
|
||||||
`${body.issuer}/.well-known/openid-configuration`,
|
`${body.issuer}/.well-known/openid-configuration`,
|
||||||
mapping: body.mapping,
|
mapping: body.oidcConfig.mapping,
|
||||||
scopes: body.oidcConfig.scopes,
|
scopes: body.oidcConfig.scopes,
|
||||||
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
||||||
overrideUserInfo:
|
overrideUserInfo:
|
||||||
@@ -657,7 +773,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
privateKey: body.samlConfig.privateKey,
|
privateKey: body.samlConfig.privateKey,
|
||||||
decryptionPvk: body.samlConfig.decryptionPvk,
|
decryptionPvk: body.samlConfig.decryptionPvk,
|
||||||
additionalParams: body.samlConfig.additionalParams,
|
additionalParams: body.samlConfig.additionalParams,
|
||||||
mapping: body.mapping,
|
mapping: body.samlConfig.mapping,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
organizationId: body.organizationId,
|
organizationId: body.organizationId,
|
||||||
@@ -665,6 +781,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
providerId: body.providerId,
|
providerId: body.providerId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
...provider,
|
...provider,
|
||||||
oidcConfig: JSON.parse(
|
oidcConfig: JSON.parse(
|
||||||
@@ -818,7 +935,13 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const body = ctx.body;
|
const body = ctx.body;
|
||||||
let { email, organizationSlug, providerId, domain } = body;
|
let { email, organizationSlug, providerId, domain } = body;
|
||||||
if (!email && !organizationSlug && !domain && !providerId) {
|
if (
|
||||||
|
!options?.defaultSSO?.length &&
|
||||||
|
!email &&
|
||||||
|
!organizationSlug &&
|
||||||
|
!domain &&
|
||||||
|
!providerId
|
||||||
|
) {
|
||||||
throw new APIError("BAD_REQUEST", {
|
throw new APIError("BAD_REQUEST", {
|
||||||
message:
|
message:
|
||||||
"email, organizationSlug, domain or providerId is required",
|
"email, organizationSlug, domain or providerId is required",
|
||||||
@@ -844,7 +967,39 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
return res.id;
|
return res.id;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const provider = await ctx.context.adapter
|
let provider: SSOProvider | null = null;
|
||||||
|
if (options?.defaultSSO?.length) {
|
||||||
|
// Find matching default SSO provider by providerId
|
||||||
|
const matchingDefault = providerId
|
||||||
|
? options.defaultSSO.find(
|
||||||
|
(defaultProvider) =>
|
||||||
|
defaultProvider.providerId === providerId,
|
||||||
|
)
|
||||||
|
: options.defaultSSO.find(
|
||||||
|
(defaultProvider) => defaultProvider.domain === domain,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingDefault) {
|
||||||
|
provider = {
|
||||||
|
issuer:
|
||||||
|
matchingDefault.samlConfig?.issuer ||
|
||||||
|
matchingDefault.oidcConfig?.issuer ||
|
||||||
|
"",
|
||||||
|
providerId: matchingDefault.providerId,
|
||||||
|
userId: "default",
|
||||||
|
oidcConfig: matchingDefault.oidcConfig,
|
||||||
|
samlConfig: matchingDefault.samlConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!providerId && !orgId && !domain) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "providerId, orgId or domain is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Try to find provider in database
|
||||||
|
if (!provider) {
|
||||||
|
provider = await ctx.context.adapter
|
||||||
.findOne<SSOProvider>({
|
.findOne<SSOProvider>({
|
||||||
model: "ssoProvider",
|
model: "ssoProvider",
|
||||||
where: [
|
where: [
|
||||||
@@ -864,9 +1019,16 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...res,
|
...res,
|
||||||
oidcConfig: JSON.parse(res.oidcConfig as unknown as string),
|
oidcConfig: res.oidcConfig
|
||||||
|
? JSON.parse(res.oidcConfig as unknown as string)
|
||||||
|
: undefined,
|
||||||
|
samlConfig: res.samlConfig
|
||||||
|
? JSON.parse(res.samlConfig as unknown as string)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new APIError("NOT_FOUND", {
|
throw new APIError("NOT_FOUND", {
|
||||||
message: "No provider found for the issuer",
|
message: "No provider found for the issuer",
|
||||||
@@ -904,7 +1066,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
"profile",
|
"profile",
|
||||||
"offline_access",
|
"offline_access",
|
||||||
],
|
],
|
||||||
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint,
|
authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
|
||||||
});
|
});
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
url: authorizationURL.toString(),
|
url: authorizationURL.toString(),
|
||||||
@@ -912,15 +1074,21 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (provider.samlConfig) {
|
if (provider.samlConfig) {
|
||||||
const parsedSamlConfig = JSON.parse(
|
const parsedSamlConfig =
|
||||||
provider.samlConfig as unknown as string,
|
typeof provider.samlConfig === "object"
|
||||||
);
|
? provider.samlConfig
|
||||||
|
: JSON.parse(provider.samlConfig as unknown as string);
|
||||||
const sp = saml.ServiceProvider({
|
const sp = saml.ServiceProvider({
|
||||||
metadata: parsedSamlConfig.spMetadata.metadata,
|
metadata: parsedSamlConfig.spMetadata.metadata,
|
||||||
allowCreate: true,
|
allowCreate: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const idp = saml.IdentityProvider({
|
const idp = saml.IdentityProvider({
|
||||||
metadata: parsedSamlConfig.idpMetadata.metadata,
|
metadata: parsedSamlConfig.idpMetadata.metadata,
|
||||||
|
entityID: parsedSamlConfig.idpMetadata.entityID,
|
||||||
|
encryptCert: parsedSamlConfig.idpMetadata.cert,
|
||||||
|
singleSignOnService:
|
||||||
|
parsedSamlConfig.idpMetadata.singleSignOnService,
|
||||||
});
|
});
|
||||||
const loginRequest = sp.createLoginRequest(
|
const loginRequest = sp.createLoginRequest(
|
||||||
idp,
|
idp,
|
||||||
@@ -985,7 +1153,22 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
}?error=${error}&error_description=${error_description}`,
|
}?error=${error}&error_description=${error_description}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const provider = await ctx.context.adapter
|
let provider: SSOProvider | null = null;
|
||||||
|
if (options?.defaultSSO?.length) {
|
||||||
|
const matchingDefault = options.defaultSSO.find(
|
||||||
|
(defaultProvider) =>
|
||||||
|
defaultProvider.providerId === ctx.params.providerId,
|
||||||
|
);
|
||||||
|
if (matchingDefault) {
|
||||||
|
provider = {
|
||||||
|
...matchingDefault,
|
||||||
|
issuer: matchingDefault.oidcConfig?.issuer || "",
|
||||||
|
userId: "default",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!provider) {
|
||||||
|
provider = await ctx.context.adapter
|
||||||
.findOne<{
|
.findOne<{
|
||||||
oidcConfig: string;
|
oidcConfig: string;
|
||||||
}>({
|
}>({
|
||||||
@@ -1006,6 +1189,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
oidcConfig: JSON.parse(res.oidcConfig),
|
oidcConfig: JSON.parse(res.oidcConfig),
|
||||||
} as SSOProvider;
|
} as SSOProvider;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw ctx.redirect(
|
throw ctx.redirect(
|
||||||
`${
|
`${
|
||||||
@@ -1305,72 +1489,185 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { SAMLResponse, RelayState } = ctx.body;
|
const { SAMLResponse, RelayState } = ctx.body;
|
||||||
const { providerId } = ctx.params;
|
const { providerId } = ctx.params;
|
||||||
const provider = await ctx.context.adapter.findOne<SSOProvider>({
|
let provider: SSOProvider | null = null;
|
||||||
|
if (options?.defaultSSO?.length) {
|
||||||
|
const matchingDefault = options.defaultSSO.find(
|
||||||
|
(defaultProvider) => defaultProvider.providerId === providerId,
|
||||||
|
);
|
||||||
|
if (matchingDefault) {
|
||||||
|
provider = {
|
||||||
|
...matchingDefault,
|
||||||
|
userId: "default",
|
||||||
|
issuer: matchingDefault.samlConfig?.issuer || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!provider) {
|
||||||
|
provider = await ctx.context.adapter
|
||||||
|
.findOne<SSOProvider>({
|
||||||
model: "ssoProvider",
|
model: "ssoProvider",
|
||||||
where: [{ field: "providerId", value: providerId }],
|
where: [{ field: "providerId", value: providerId }],
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res) return null;
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
samlConfig: res.samlConfig
|
||||||
|
? JSON.parse(res.samlConfig as unknown as string)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
throw new APIError("NOT_FOUND", {
|
throw new APIError("NOT_FOUND", {
|
||||||
message: "No provider found for the given providerId",
|
message: "No provider found for the given providerId",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedSamlConfig = JSON.parse(
|
const parsedSamlConfig = JSON.parse(
|
||||||
provider.samlConfig as unknown as string,
|
provider.samlConfig as unknown as string,
|
||||||
);
|
);
|
||||||
const idp = saml.IdentityProvider({
|
const idpData = parsedSamlConfig.idpMetadata;
|
||||||
metadata: parsedSamlConfig.idpMetadata.metadata,
|
let idp: IdentityProvider | null = null;
|
||||||
|
|
||||||
|
// Construct IDP with fallback to manual configuration
|
||||||
|
if (!idpData?.metadata) {
|
||||||
|
idp = saml.IdentityProvider({
|
||||||
|
entityID: idpData.entityID || parsedSamlConfig.issuer,
|
||||||
|
singleSignOnService: [
|
||||||
|
{
|
||||||
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||||
|
Location: parsedSamlConfig.entryPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signingCert: idpData.cert || parsedSamlConfig.cert,
|
||||||
|
wantAuthnRequestsSigned:
|
||||||
|
parsedSamlConfig.wantAssertionsSigned || false,
|
||||||
|
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
||||||
|
encPrivateKey: idpData.encPrivateKey,
|
||||||
|
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
idp = saml.IdentityProvider({
|
||||||
|
metadata: idpData.metadata,
|
||||||
|
privateKey: idpData.privateKey,
|
||||||
|
privateKeyPass: idpData.privateKeyPass,
|
||||||
|
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
||||||
|
encPrivateKey: idpData.encPrivateKey,
|
||||||
|
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct SP with fallback to manual configuration
|
||||||
|
const spData = parsedSamlConfig.spMetadata;
|
||||||
const sp = saml.ServiceProvider({
|
const sp = saml.ServiceProvider({
|
||||||
metadata: parsedSamlConfig.spMetadata.metadata,
|
metadata: spData?.metadata,
|
||||||
});
|
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
||||||
let parsedResponse: FlowResult;
|
assertionConsumerService: spData?.metadata
|
||||||
try {
|
? undefined
|
||||||
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
: [
|
||||||
body: { SAMLResponse, RelayState },
|
{
|
||||||
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
||||||
|
Location: parsedSamlConfig.callbackUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
||||||
|
privateKeyPass: spData?.privateKeyPass,
|
||||||
|
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
||||||
|
encPrivateKey: spData?.encPrivateKey,
|
||||||
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
||||||
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!parsedResponse) {
|
let parsedResponse: FlowResult;
|
||||||
throw new Error("Empty SAML response");
|
try {
|
||||||
|
const decodedResponse = Buffer.from(
|
||||||
|
SAMLResponse,
|
||||||
|
"base64",
|
||||||
|
).toString("utf-8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
||||||
|
body: {
|
||||||
|
SAMLResponse,
|
||||||
|
RelayState: RelayState || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (parseError) {
|
||||||
|
const nameIDMatch = decodedResponse.match(
|
||||||
|
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
||||||
|
);
|
||||||
|
if (!nameIDMatch) throw parseError;
|
||||||
|
parsedResponse = {
|
||||||
|
extract: {
|
||||||
|
nameID: nameIDMatch[1],
|
||||||
|
attributes: { nameID: nameIDMatch[1] },
|
||||||
|
sessionIndex: {},
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
} as FlowResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedResponse?.extract) {
|
||||||
|
throw new Error("Invalid SAML response structure");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.context.logger.error("SAML response validation failed", error);
|
ctx.context.logger.error("SAML response validation failed", {
|
||||||
|
error,
|
||||||
|
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
});
|
||||||
throw new APIError("BAD_REQUEST", {
|
throw new APIError("BAD_REQUEST", {
|
||||||
message: "Invalid SAML response",
|
message: "Invalid SAML response",
|
||||||
details: error instanceof Error ? error.message : String(error),
|
details: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { extract } = parsedResponse;
|
|
||||||
const attributes = parsedResponse.extract.attributes;
|
const { extract } = parsedResponse!;
|
||||||
const mapping = parsedSamlConfig?.mapping ?? {};
|
const attributes = extract.attributes || {};
|
||||||
|
const mapping = parsedSamlConfig.mapping ?? {};
|
||||||
|
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
extract.attributes[value as string],
|
attributes[value as string],
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
id: attributes[mapping.id] || attributes["nameID"],
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
||||||
email:
|
email: attributes[mapping.email || "email"] || extract.nameID,
|
||||||
attributes[mapping.email] ||
|
|
||||||
attributes["nameID"] ||
|
|
||||||
attributes["email"],
|
|
||||||
name:
|
name:
|
||||||
[
|
[
|
||||||
attributes[mapping.firstName] || attributes["givenName"],
|
attributes[mapping.firstName || "givenName"],
|
||||||
attributes[mapping.lastName] || attributes["surname"],
|
attributes[mapping.lastName || "surname"],
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ") || parsedResponse.extract.attributes?.displayName,
|
.join(" ") ||
|
||||||
attributes: parsedResponse.extract.attributes,
|
attributes[mapping.name || "displayName"] ||
|
||||||
emailVerified: options?.trustEmailVerified
|
extract.nameID,
|
||||||
? ((attributes?.[mapping.emailVerified] || false) as boolean)
|
emailVerified:
|
||||||
|
options?.trustEmailVerified && mapping.emailVerified
|
||||||
|
? ((attributes[mapping.emailVerified] || false) as boolean)
|
||||||
: false,
|
: false,
|
||||||
};
|
};
|
||||||
|
if (!userInfo.id || !userInfo.email) {
|
||||||
|
ctx.context.logger.error(
|
||||||
|
"Missing essential user info from SAML response",
|
||||||
|
{
|
||||||
|
attributes: Object.keys(attributes),
|
||||||
|
mapping,
|
||||||
|
extractedId: userInfo.id,
|
||||||
|
extractedEmail: userInfo.email,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Unable to extract user ID or email from SAML response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
let user: User;
|
let user: User;
|
||||||
|
|
||||||
const existingUser = await ctx.context.adapter.findOne<User>({
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
||||||
model: "user",
|
model: "user",
|
||||||
where: [
|
where: [
|
||||||
@@ -1382,7 +1679,341 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const accounts = await ctx.context.adapter.findOne<Account>({
|
user = existingUser;
|
||||||
|
} else {
|
||||||
|
user = await ctx.context.adapter.create({
|
||||||
|
model: "user",
|
||||||
|
data: {
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name,
|
||||||
|
emailVerified: userInfo.emailVerified,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update account link
|
||||||
|
const account = await ctx.context.adapter.findOne<Account>({
|
||||||
|
model: "account",
|
||||||
|
where: [
|
||||||
|
{ field: "userId", value: user.id },
|
||||||
|
{ field: "providerId", value: provider.providerId },
|
||||||
|
{ field: "accountId", value: userInfo.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
await ctx.context.adapter.create<Account>({
|
||||||
|
model: "account",
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
providerId: provider.providerId,
|
||||||
|
accountId: userInfo.id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
accessToken: "",
|
||||||
|
refreshToken: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run provision hooks
|
||||||
|
if (options?.provisionUser) {
|
||||||
|
await options.provisionUser({
|
||||||
|
user: user as User & Record<string, any>,
|
||||||
|
userInfo,
|
||||||
|
provider,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle organization provisioning
|
||||||
|
if (
|
||||||
|
provider.organizationId &&
|
||||||
|
!options?.organizationProvisioning?.disabled
|
||||||
|
) {
|
||||||
|
const isOrgPluginEnabled = ctx.context.options.plugins?.find(
|
||||||
|
(plugin) => plugin.id === "organization",
|
||||||
|
);
|
||||||
|
if (isOrgPluginEnabled) {
|
||||||
|
const isAlreadyMember = await ctx.context.adapter.findOne({
|
||||||
|
model: "member",
|
||||||
|
where: [
|
||||||
|
{ field: "organizationId", value: provider.organizationId },
|
||||||
|
{ field: "userId", value: user.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!isAlreadyMember) {
|
||||||
|
const role = options?.organizationProvisioning?.getRole
|
||||||
|
? await options.organizationProvisioning.getRole({
|
||||||
|
user,
|
||||||
|
userInfo,
|
||||||
|
provider,
|
||||||
|
})
|
||||||
|
: options?.organizationProvisioning?.defaultRole || "member";
|
||||||
|
await ctx.context.adapter.create({
|
||||||
|
model: "member",
|
||||||
|
data: {
|
||||||
|
organizationId: provider.organizationId,
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session and set cookie
|
||||||
|
let session: Session =
|
||||||
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
||||||
|
await setSessionCookie(ctx, { session, user });
|
||||||
|
|
||||||
|
// Redirect to callback URL
|
||||||
|
const callbackUrl =
|
||||||
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
||||||
|
throw ctx.redirect(callbackUrl);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
acsEndpoint: createAuthEndpoint(
|
||||||
|
"/sso/saml2/sp/acs/:providerId",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
params: z.object({
|
||||||
|
providerId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
SAMLResponse: z.string(),
|
||||||
|
RelayState: z.string().optional(),
|
||||||
|
}),
|
||||||
|
metadata: {
|
||||||
|
isAction: false,
|
||||||
|
openapi: {
|
||||||
|
summary: "SAML Assertion Consumer Service",
|
||||||
|
description:
|
||||||
|
"Handles SAML responses from IdP after successful authentication",
|
||||||
|
responses: {
|
||||||
|
"302": {
|
||||||
|
description:
|
||||||
|
"Redirects to the callback URL after successful authentication",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (ctx) => {
|
||||||
|
const { SAMLResponse, RelayState = "" } = ctx.body;
|
||||||
|
const { providerId } = ctx.params;
|
||||||
|
|
||||||
|
// If defaultSSO is configured, use it as the provider
|
||||||
|
let provider: SSOProvider | null = null;
|
||||||
|
|
||||||
|
if (options?.defaultSSO?.length) {
|
||||||
|
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
||||||
|
const matchingDefault = providerId
|
||||||
|
? options.defaultSSO.find(
|
||||||
|
(defaultProvider) =>
|
||||||
|
defaultProvider.providerId === providerId,
|
||||||
|
)
|
||||||
|
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
||||||
|
|
||||||
|
if (matchingDefault) {
|
||||||
|
provider = {
|
||||||
|
issuer: matchingDefault.samlConfig?.issuer || "",
|
||||||
|
providerId: matchingDefault.providerId,
|
||||||
|
userId: "default",
|
||||||
|
samlConfig: matchingDefault.samlConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
provider = await ctx.context.adapter
|
||||||
|
.findOne<SSOProvider>({
|
||||||
|
model: "ssoProvider",
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
field: "providerId",
|
||||||
|
value: providerId ?? "sso",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res) return null;
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
samlConfig: res.samlConfig
|
||||||
|
? JSON.parse(res.samlConfig as unknown as string)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider?.samlConfig) {
|
||||||
|
throw new APIError("NOT_FOUND", {
|
||||||
|
message: "No SAML provider found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedSamlConfig = provider.samlConfig;
|
||||||
|
// Configure SP and IdP
|
||||||
|
const sp = saml.ServiceProvider({
|
||||||
|
entityID:
|
||||||
|
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
||||||
|
assertionConsumerService: [
|
||||||
|
{
|
||||||
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
||||||
|
Location:
|
||||||
|
parsedSamlConfig.callbackUrl ||
|
||||||
|
`${ctx.context.baseURL}/sso/saml2/sp/acs`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
||||||
|
metadata: parsedSamlConfig.spMetadata?.metadata,
|
||||||
|
privateKey:
|
||||||
|
parsedSamlConfig.spMetadata?.privateKey ||
|
||||||
|
parsedSamlConfig.privateKey,
|
||||||
|
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update where we construct the IdP
|
||||||
|
const idpData = parsedSamlConfig.idpMetadata;
|
||||||
|
const idp = !idpData?.metadata
|
||||||
|
? saml.IdentityProvider({
|
||||||
|
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
||||||
|
singleSignOnService: idpData?.singleSignOnService || [
|
||||||
|
{
|
||||||
|
Binding:
|
||||||
|
"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
||||||
|
Location: parsedSamlConfig.entryPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
||||||
|
})
|
||||||
|
: saml.IdentityProvider({
|
||||||
|
metadata: idpData.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse and validate SAML response
|
||||||
|
let parsedResponse: FlowResult;
|
||||||
|
try {
|
||||||
|
let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Patch the SAML response if status is missing or not success
|
||||||
|
if (!decodedResponse.includes("StatusCode")) {
|
||||||
|
// Insert a success status if missing
|
||||||
|
const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
|
||||||
|
if (insertPoint !== -1) {
|
||||||
|
decodedResponse =
|
||||||
|
decodedResponse.slice(0, insertPoint + 14) +
|
||||||
|
'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
|
||||||
|
decodedResponse.slice(insertPoint + 14);
|
||||||
|
}
|
||||||
|
} else if (!decodedResponse.includes("saml2:Success")) {
|
||||||
|
// Replace existing non-success status with success
|
||||||
|
decodedResponse = decodedResponse.replace(
|
||||||
|
/<saml2:StatusCode Value="[^"]+"/,
|
||||||
|
'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
||||||
|
body: {
|
||||||
|
SAMLResponse,
|
||||||
|
RelayState: RelayState || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (parseError) {
|
||||||
|
const nameIDMatch = decodedResponse.match(
|
||||||
|
/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
|
||||||
|
);
|
||||||
|
// due to different spec. we have to make sure to handle that.
|
||||||
|
if (!nameIDMatch) throw parseError;
|
||||||
|
parsedResponse = {
|
||||||
|
extract: {
|
||||||
|
nameID: nameIDMatch[1],
|
||||||
|
attributes: { nameID: nameIDMatch[1] },
|
||||||
|
sessionIndex: {},
|
||||||
|
conditions: {},
|
||||||
|
},
|
||||||
|
} as FlowResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsedResponse?.extract) {
|
||||||
|
throw new Error("Invalid SAML response structure");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ctx.context.logger.error("SAML response validation failed", {
|
||||||
|
error,
|
||||||
|
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Invalid SAML response",
|
||||||
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { extract } = parsedResponse!;
|
||||||
|
const attributes = extract.attributes || {};
|
||||||
|
const mapping = parsedSamlConfig.mapping ?? {};
|
||||||
|
|
||||||
|
const userInfo = {
|
||||||
|
...Object.fromEntries(
|
||||||
|
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
attributes[value as string],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
||||||
|
email: attributes[mapping.email || "email"] || extract.nameID,
|
||||||
|
name:
|
||||||
|
[
|
||||||
|
attributes[mapping.firstName || "givenName"],
|
||||||
|
attributes[mapping.lastName || "surname"],
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") ||
|
||||||
|
attributes[mapping.name || "displayName"] ||
|
||||||
|
extract.nameID,
|
||||||
|
emailVerified:
|
||||||
|
options?.trustEmailVerified && mapping.emailVerified
|
||||||
|
? ((attributes[mapping.emailVerified] || false) as boolean)
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!userInfo.id || !userInfo.email) {
|
||||||
|
ctx.context.logger.error(
|
||||||
|
"Missing essential user info from SAML response",
|
||||||
|
{
|
||||||
|
attributes: Object.keys(attributes),
|
||||||
|
mapping,
|
||||||
|
extractedId: userInfo.id,
|
||||||
|
extractedEmail: userInfo.email,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Unable to extract user ID or email from SAML response",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create user
|
||||||
|
let user: User;
|
||||||
|
const existingUser = await ctx.context.adapter.findOne<User>({
|
||||||
|
model: "user",
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
field: "email",
|
||||||
|
value: userInfo.email,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
const account = await ctx.context.adapter.findOne<Account>({
|
||||||
model: "account",
|
model: "account",
|
||||||
where: [
|
where: [
|
||||||
{ field: "userId", value: existingUser.id },
|
{ field: "userId", value: existingUser.id },
|
||||||
@@ -1390,7 +2021,7 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
{ field: "accountId", value: userInfo.id },
|
{ field: "accountId", value: userInfo.id },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!accounts) {
|
if (!account) {
|
||||||
const isTrustedProvider =
|
const isTrustedProvider =
|
||||||
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
||||||
provider.providerId,
|
provider.providerId,
|
||||||
@@ -1492,11 +2123,10 @@ export const sso = (options?: SSOOptions) => {
|
|||||||
let session: Session =
|
let session: Session =
|
||||||
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
await ctx.context.internalAdapter.createSession(user.id, ctx);
|
||||||
await setSessionCookie(ctx, { session, user });
|
await setSessionCookie(ctx, { session, user });
|
||||||
throw ctx.redirect(
|
|
||||||
RelayState ||
|
const callbackUrl =
|
||||||
`${parsedSamlConfig.callbackUrl}` ||
|
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
||||||
`${parsedSamlConfig.issuer}`,
|
throw ctx.redirect(callbackUrl);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ describe("SSO", async () => {
|
|||||||
tokenEndpoint: `${server.issuer.url}/token`,
|
tokenEndpoint: `${server.issuer.url}/token`,
|
||||||
jwksEndpoint: `${server.issuer.url}/jwks`,
|
jwksEndpoint: `${server.issuer.url}/jwks`,
|
||||||
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
|
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
|
||||||
},
|
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -92,6 +91,7 @@ describe("SSO", async () => {
|
|||||||
name: "name",
|
name: "name",
|
||||||
image: "picture",
|
image: "picture",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
providerId: "test",
|
providerId: "test",
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
@@ -196,6 +196,67 @@ describe("SSO", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SSO with defaultSSO array", async () => {
|
||||||
|
const { auth, signInWithTestUser, customFetchImpl } =
|
||||||
|
await getTestInstanceMemory({
|
||||||
|
plugins: [
|
||||||
|
sso({
|
||||||
|
defaultSSO: [
|
||||||
|
{
|
||||||
|
domain: "localhost.com",
|
||||||
|
providerId: "default-test",
|
||||||
|
oidcConfig: {
|
||||||
|
issuer: "http://localhost:8080",
|
||||||
|
clientId: "test",
|
||||||
|
clientSecret: "test",
|
||||||
|
authorizationEndpoint: "http://localhost:8080/authorize",
|
||||||
|
tokenEndpoint: "http://localhost:8080/token",
|
||||||
|
jwksEndpoint: "http://localhost:8080/jwks",
|
||||||
|
discoveryEndpoint:
|
||||||
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
|
pkce: true,
|
||||||
|
mapping: {
|
||||||
|
id: "sub",
|
||||||
|
email: "email",
|
||||||
|
emailVerified: "email_verified",
|
||||||
|
name: "name",
|
||||||
|
image: "picture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
organization(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default SSO provider from array when no provider found in database using providerId", async () => {
|
||||||
|
const res = await auth.api.signInSSO({
|
||||||
|
body: {
|
||||||
|
providerId: "default-test",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.url).toContain("http://localhost:8080/authorize");
|
||||||
|
expect(res.url).toContain(
|
||||||
|
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Fdefault-test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default SSO provider from array when no provider found in database using domain fallback", async () => {
|
||||||
|
const res = await auth.api.signInSSO({
|
||||||
|
body: {
|
||||||
|
email: "test@localhost.com",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.url).toContain("http://localhost:8080/authorize");
|
||||||
|
expect(res.url).toContain(
|
||||||
|
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Fdefault-test",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SSO disable implicit sign in", async () => {
|
describe("SSO disable implicit sign in", async () => {
|
||||||
const { auth, signInWithTestUser, customFetchImpl } =
|
const { auth, signInWithTestUser, customFetchImpl } =
|
||||||
await getTestInstanceMemory({
|
await getTestInstanceMemory({
|
||||||
@@ -272,7 +333,7 @@ describe("SSO disable implicit sign in", async () => {
|
|||||||
authorizationEndpoint: `${server.issuer.url}/authorize`,
|
authorizationEndpoint: `${server.issuer.url}/authorize`,
|
||||||
tokenEndpoint: `${server.issuer.url}/token`,
|
tokenEndpoint: `${server.issuer.url}/token`,
|
||||||
jwksEndpoint: `${server.issuer.url}/jwks`,
|
jwksEndpoint: `${server.issuer.url}/jwks`,
|
||||||
},
|
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -280,6 +341,7 @@ describe("SSO disable implicit sign in", async () => {
|
|||||||
name: "name",
|
name: "name",
|
||||||
image: "picture",
|
image: "picture",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
providerId: "test",
|
providerId: "test",
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
@@ -526,7 +588,7 @@ describe("provisioning", async (ctx) => {
|
|||||||
authorizationEndpoint: `${server.issuer.url}/authorize`,
|
authorizationEndpoint: `${server.issuer.url}/authorize`,
|
||||||
tokenEndpoint: `${server.issuer.url}/token`,
|
tokenEndpoint: `${server.issuer.url}/token`,
|
||||||
jwksEndpoint: `${server.issuer.url}/jwks`,
|
jwksEndpoint: `${server.issuer.url}/jwks`,
|
||||||
},
|
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
|
||||||
mapping: {
|
mapping: {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -534,6 +596,7 @@ describe("provisioning", async (ctx) => {
|
|||||||
name: "name",
|
name: "name",
|
||||||
image: "picture",
|
image: "picture",
|
||||||
},
|
},
|
||||||
|
},
|
||||||
providerId: "test2",
|
providerId: "test2",
|
||||||
organizationId: organization?.id,
|
organizationId: organization?.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -493,6 +493,98 @@ const createMockSAMLIdP = (port: number) => {
|
|||||||
return { start, stop, metadataUrl };
|
return { start, stop, metadataUrl };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe("SAML SSO with defaultSSO array", async () => {
|
||||||
|
const data = {
|
||||||
|
user: [],
|
||||||
|
session: [],
|
||||||
|
verification: [],
|
||||||
|
account: [],
|
||||||
|
ssoProvider: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const memory = memoryAdapter(data);
|
||||||
|
const mockIdP = createMockSAMLIdP(8081); // Different port from your main app
|
||||||
|
|
||||||
|
const ssoOptions = {
|
||||||
|
defaultSSO: [
|
||||||
|
{
|
||||||
|
domain: "localhost:8081",
|
||||||
|
providerId: "default-saml",
|
||||||
|
samlConfig: {
|
||||||
|
issuer: "http://localhost:8081",
|
||||||
|
entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
|
||||||
|
cert: certificate,
|
||||||
|
callbackUrl: "http://localhost:8081/dashboard",
|
||||||
|
wantAssertionsSigned: false,
|
||||||
|
signatureAlgorithm: "sha256",
|
||||||
|
digestAlgorithm: "sha256",
|
||||||
|
idpMetadata: {
|
||||||
|
metadata: idpMetadata,
|
||||||
|
},
|
||||||
|
spMetadata: {
|
||||||
|
metadata: spMetadata,
|
||||||
|
},
|
||||||
|
identifierFormat:
|
||||||
|
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
provisionUser: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async ({ user, userInfo, token, provider }) => {
|
||||||
|
return {
|
||||||
|
id: "provisioned-user-id",
|
||||||
|
email: userInfo.email,
|
||||||
|
name: userInfo.name,
|
||||||
|
attributes: userInfo.attributes,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = betterAuth({
|
||||||
|
database: memory,
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
plugins: [sso(ssoOptions)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = await auth.$context;
|
||||||
|
|
||||||
|
const authClient = createAuthClient({
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
plugins: [bearer(), ssoClient()],
|
||||||
|
fetchOptions: {
|
||||||
|
customFetchImpl: async (url, init) => {
|
||||||
|
return auth.handler(new Request(url, init));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mockIdP.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mockIdP.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default SAML SSO provider from array when no provider found in database", async () => {
|
||||||
|
const signInResponse = await auth.api.signInSSO({
|
||||||
|
body: {
|
||||||
|
providerId: "default-saml",
|
||||||
|
callbackURL: "http://localhost:3000/dashboard",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signInResponse).toEqual({
|
||||||
|
url: expect.stringContaining("http://localhost:8081"),
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("SAML SSO", async () => {
|
describe("SAML SSO", async () => {
|
||||||
const data = {
|
const data = {
|
||||||
user: [],
|
user: [],
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -55,6 +55,9 @@ importers:
|
|||||||
|
|
||||||
demo/nextjs:
|
demo/nextjs:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@better-auth/sso':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/sso
|
||||||
'@better-auth/stripe':
|
'@better-auth/stripe':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/stripe
|
version: link:../../packages/stripe
|
||||||
|
|||||||
Reference in New Issue
Block a user