Compare commits

...

18 Commits

Author SHA1 Message Date
Supertokens Bot c9e8287b30 adding dev-v11.3.0 tag to this commit to ensure building 2025-11-19 14:05:30 +00:00
Sattvik Chakravarthy 5f2e57ba31
feat: SAML (#1192)
* fix: outline

* fix: login redirect impl

* fix: handle SAML callback

* fix: saml cert management

* fix: authn request signing

* fix: working login for azure

* fix: save idp entity id

* fix: use client id from relay state info

* fix: create or update saml client

* fix: generate clientId

* fix: list SAML Clients

* fix: remove saml client

* fix: saml callback and token

* fix: idp flow

* fix: remove unnecessary logging

* fix: apis to work like boxy

* fix: add support for legacy SAML ACS URL and enhance SAML client management

* fix: enforce public tenant in legacy APIs

* fix: client secret checking in legacy API

* fix: cronjob to cleanup saml codes

* fix: tests

* fix: version update

* test: create or update saml client

* test: list and delete saml client

* test: create saml login redirect

* test: bad inputs for handle saml callback

* fix: expiration handling

* test: SAML audience check

* fix: enable request signing

* fix: remove metadata url and add enable request signing

* fix: remaining tests

* fix: idp flow tests

* fix: tests

* fix: remove sp entity id from client

* fix: saml feature check

* fix: unique idp entity id

* fix: sp metadata and featureflag test

* fix: tests

* fix: global logging level

* fix: changelog

* fix: SAML client count

* fix: saml stats

* fix: SAML certificate refresh

* fix: SAML metadata API

* fix: tests

* fix: not loading keys on tenant creation

* fix: deadlock

* fix: removing deadlock causing code

* fix: removing locks

* Revert "fix: deadlock"

This reverts commit 2d5a07c7e0.

* fix: index for expires_at

* fix: rename saml cleanup cron task

* experiment: Deadlock logger (#1198)

* experiment: Deadlock logger

* fix: race issue with oauth refresh (#1199)

* fix: race issue with oauth refresh

* fix: review comment

* fix: remove print

* fix: deadlock in resource distributor (#1197)

* adding dev-v11.2.1 tag to this commit to ensure building

* fix: add deadlock logger

* fix: changelog and build version

* fix: only start deadlocklogger if it's enabled

---------

Co-authored-by: Sattvik Chakravarthy <sattvik@supertokens.com>
Co-authored-by: Supertokens Bot <>

* fix: tests

* fix: tests

* fix: inmemory tests

* fix: gradle

* fix: deadlock in delete table

* fix: in memory test for concurrency

* fix: configurable claims and relay state validity and cleanup

* fix: generating secure random for serial number

* fix: bulk import chunking

* fix: revert lock related changes

* fix: auto commit

* fix: revert bulk import

* fix: auto commit

---------

Co-authored-by: Tamas Soltesz <tamas@supertokens.com>
2025-11-19 19:30:41 +05:30
Supertokens Bot bce323172f adding dev-v11.2.1 tag to this commit to ensure building 2025-11-12 11:42:19 +00:00
Sattvik Chakravarthy 1312069b0d
fix: deadlock in resource distributor (#1197) 2025-11-12 17:01:52 +05:30
Sattvik Chakravarthy 1b1b2b2bdf
fix: race issue with oauth refresh (#1199)
* fix: race issue with oauth refresh

* fix: review comment

* fix: remove print
2025-11-12 16:22:42 +05:30
Supertokens Bot e2ce5828f0 adding dev-v11.2.0 tag to this commit to ensure building 2025-10-28 07:42:28 +00:00
Tamas Soltesz 8ee1665c72
feat: otel javaagent (#1189)
* feat: add java cli options for javaagent and jmx

* fix: gitignore changes to allow resources jars

* feat: add otel javaagent jars

* fix: loading agent from the agent dir

* fix: changelog and build version

* fix: proper agent attach command on unix-like systems

* feat: aspect for wrapping methods in otel span

* fix: add exception records to spans

* fix: use javaagent's otel if present

* fix: fixing agent loading at startup

* fix: add logging for the command that's being run

* fix: pinpoint runner image version

* fix: pinpoint runner image version

* fix: tell the core branch name for the workflow

* fix: build docker image with the right branches

* fix: publish-dev-docker formatting

* fix: using an other variable for deciding the current branch name

* fix: using an other variable for deciding the current branch name

* fix: update cli dependencies to make it the same version as core

* fix: update implementationDependencies.json with aspectjrt

* fix: update build version

---------

Co-authored-by: Sattvik Chakravarthy <sattvik@supertokens.com>
2025-10-28 13:08:57 +05:30
Supertokens Bot d524bdb9d9 adding dev-v11.1.1 tag to this commit to ensure building 2025-10-22 10:48:20 +00:00
Sattvik Chakravarthy be57314b35
Update dev-tag.yml 2025-10-22 16:15:43 +05:30
Supertokens Bot 34eeda50fe adding dev-v11.1.1 tag to this commit to ensure building 2025-10-22 10:41:31 +00:00
Sattvik Chakravarthy 058f6f69df
Change run-for parameter in dev-tag workflow 2025-10-22 16:08:51 +05:30
Supertokens Bot e671bcb492 adding dev-v11.1.1 tag to this commit to ensure building 2025-10-22 10:14:14 +00:00
Tamas Soltesz c4e8afbcd1
fix: update tomcat-embed to go beyond known cves (#1193) 2025-10-22 14:41:16 +05:30
Supertokens Bot 384d688b85 adding dev-v11.1.0 tag to this commit to ensure building 2025-09-01 07:11:10 +00:00
Supertokens Bot 97dfa59faf adding dev-v11.1.0 tag to this commit to ensure building 2025-08-29 10:57:12 +00:00
Sattvik Chakravarthy 9d3253f71e
fix: test slowness due to invalid otel url (#1184)
* fix: test slowness due to invalid otel url

* fix: domain fix and changelog
2025-08-29 16:24:06 +05:30
Supertokens Bot 9b84d8cdef adding dev-v11.1.0 tag to this commit to ensure building 2025-08-28 12:17:46 +00:00
Tamas Soltesz 879292770a
feat: add hikari logs to otel (#1181)
* feat: add hikari logs to otel

* fix: add env name annotations

* fix: update core config and config mapper to load from env

* chore: update build version and changelog

* fix: config test

* fix: config json from env

* fix: changelog

* fix: storage class loader

---------

Co-authored-by: Sattvik Chakravarthy <sattvik@gmail.com>
2025-08-28 17:40:51 +05:30
94 changed files with 7448 additions and 230 deletions

View File

@ -19,6 +19,8 @@ jobs:
id: result
with:
run-for: PR
core-branch: ${{ github.ref_name }}
docker:
name: Docker
runs-on: ubuntu-latest

View File

@ -10,7 +10,7 @@ on:
jobs:
stress-tests:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Set up Node.js

View File

@ -6,7 +6,7 @@ on:
jobs:
dependency-branches:
name: Dependency Branches
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
outputs:
branches: ${{ steps.result.outputs.branches }}
@ -16,6 +16,7 @@ jobs:
id: result
with:
run-for: PR
core-branch: ${{ github.head_ref }}
test:
name: Unit tests
@ -30,7 +31,7 @@ jobs:
# - mysql
# - mongodb
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Set up JDK 21.0.7
uses: actions/setup-java@v2

1
.gitignore vendored
View File

@ -12,6 +12,7 @@ gradle-app.setting
!cli/jar/**/*.jar
!downloader/jar/**/*.jar
!ee/jar/**/*.jar
!src/main/resources/**/*.jar
*target*
*.war

View File

@ -7,6 +7,89 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [11.3.0]
- Adds SAML features
- Fixes potential deadlock issue with `TelemetryProvider`
- Adds DeadlockLogger as an utility for discovering deadlock issues
### Migration
```sql
CREATE TABLE IF NOT EXISTS saml_clients (
app_id VARCHAR(64) NOT NULL DEFAULT 'public',
tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',
client_id VARCHAR(256) NOT NULL,
client_secret TEXT,
sso_login_url TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
default_redirect_uri TEXT NOT NULL,
idp_entity_id VARCHAR(256) NOT NULL,
idp_signing_certificate TEXT NOT NULL,
allow_idp_initiated_login BOOLEAN NOT NULL DEFAULT FALSE,
enable_request_signing BOOLEAN NOT NULL DEFAULT FALSE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT saml_clients_pkey PRIMARY KEY(app_id, tenant_id, client_id),
CONSTRAINT saml_clients_idp_entity_id_key UNIQUE (app_id, tenant_id, idp_entity_id),
CONSTRAINT saml_clients_app_id_fkey FOREIGN KEY(app_id) REFERENCES apps (app_id) ON DELETE CASCADE,
CONSTRAINT saml_clients_tenant_id_fkey FOREIGN KEY(app_id, tenant_id) REFERENCES tenants (app_id, tenant_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS saml_clients_app_id_tenant_id_index ON saml_clients (app_id, tenant_id);
CREATE TABLE IF NOT EXISTS saml_relay_state (
app_id VARCHAR(64) NOT NULL DEFAULT 'public',
tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',
relay_state VARCHAR(256) NOT NULL,
client_id VARCHAR(256) NOT NULL,
state TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
created_at BIGINT NOT NULL,
CONSTRAINT saml_relay_state_pkey PRIMARY KEY(app_id, tenant_id, relay_state),
CONSTRAINT saml_relay_state_app_id_fkey FOREIGN KEY(app_id) REFERENCES apps (app_id) ON DELETE CASCADE,
CONSTRAINT saml_relay_state_tenant_id_fkey FOREIGN KEY(app_id, tenant_id) REFERENCES tenants (app_id, tenant_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS saml_relay_state_app_id_tenant_id_index ON saml_relay_state (app_id, tenant_id);
CREATE INDEX IF NOT EXISTS saml_relay_state_expires_at_index ON saml_relay_state (expires_at);
CREATE TABLE IF NOT EXISTS saml_claims (
app_id VARCHAR(64) NOT NULL DEFAULT 'public',
tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',
client_id VARCHAR(256) NOT NULL,
code VARCHAR(256) NOT NULL,
claims TEXT NOT NULL,
created_at BIGINT NOT NULL,
CONSTRAINT saml_claims_pkey PRIMARY KEY(app_id, tenant_id, code),
CONSTRAINT saml_claims_app_id_fkey FOREIGN KEY(app_id) REFERENCES apps (app_id) ON DELETE CASCADE,
CONSTRAINT saml_claims_tenant_id_fkey FOREIGN KEY(app_id, tenant_id) REFERENCES tenants (app_id, tenant_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS saml_claims_app_id_tenant_id_index ON saml_claims (app_id, tenant_id);
CREATE INDEX IF NOT EXISTS saml_claims_expires_at_index ON saml_claims (expires_at);
```
## [11.2.1]
- Fixes deadlock issue with `ResourceDistributor`
- Fixes race issues with Refreshing OAuth token
## [11.2.0]
- Adds opentelemetry-javaagent to the core distribution
## [11.1.1]
- Updates tomcat-embed to 11.0.12 because of security vulnerabilities
## [11.1.0]
- Adds hikari logs to opentelemetry
- Fetches core and plugin config from env
- Open Telemetry configuration is now optional
- Migrates API calls from supertokens.io to supertokens.com
## [11.0.5]
- Adds all logs to telemetry which were logged with `io/supertokens/output/Logging.java`

View File

@ -9,6 +9,7 @@
plugins {
id 'application'
id 'java-library'
id "io.freefair.aspectj" version "8.13" //same as gradle version!
}
compileJava { options.encoding = "UTF-8" }
compileTestJava { options.encoding = "UTF-8" }
@ -26,10 +27,12 @@ java {
}
}
version = "11.0.5"
version = "11.3.0"
repositories {
mavenCentral()
maven { url 'https://build.shibboleth.net/nexus/content/repositories/releases/' }
}
dependencies {
@ -47,9 +50,8 @@ dependencies {
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.18.2'
// https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-core
api group: 'org.apache.tomcat.embed', name: 'tomcat-embed-core', version: '11.0.8'
api group: 'org.apache.tomcat.embed', name: 'tomcat-embed-core', version: '11.0.12'
// https://mvnrepository.com/artifact/com.google.code.findbugs/jsr305
implementation group: 'com.google.code.findbugs', name: 'jsr305', version: '3.0.2'
@ -86,11 +88,16 @@ dependencies {
implementation platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom-alpha:2.17.0-alpha")
// Open SAML
implementation group: 'org.opensaml', name: 'opensaml-core', version: '4.3.1'
implementation group: 'org.opensaml', name: 'opensaml-saml-impl', version: '4.3.1'
implementation group: 'org.opensaml', name: 'opensaml-security-impl', version: '4.3.1'
implementation group: 'org.opensaml', name: 'opensaml-profile-impl', version: '4.3.1'
implementation group: 'org.opensaml', name: 'opensaml-xmlsec-impl', version: '4.3.1'
implementation("ch.qos.logback:logback-core:1.5.18")
implementation("ch.qos.logback:logback-classic:1.5.18")
// OpenTelemetry core
implementation("io.opentelemetry:opentelemetry-sdk")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
@ -99,6 +106,7 @@ dependencies {
implementation("io.opentelemetry.semconv:opentelemetry-semconv")
implementation('org.aspectj:aspectjrt:1.9.24')
compileOnly project(":supertokens-plugin-interface")

View File

@ -4,6 +4,8 @@ plugins {
repositories {
mavenCentral()
maven { url 'https://build.shibboleth.net/nexus/content/repositories/releases/' }
}
application {
@ -19,10 +21,10 @@ dependencies {
implementation group: 'com.google.code.gson', name: 'gson', version: '2.13.1'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.dataformat/jackson-dataformat-yaml
implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.16.1'
implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.18.2'
// https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.1'
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.18.2'
// https://mvnrepository.com/artifact/de.mkammerer/argon2-jvm
implementation group: 'de.mkammerer', name: 'argon2-jvm', version: '2.11'

View File

@ -12,19 +12,19 @@
"src":"https://repo.maven.apache.org/maven2/com/google/errorprone/error_prone_annotations/2.38.0/error_prone_annotations-2.38.0-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.16.1/jackson-dataformat-yaml-2.16.1.jar",
"name":"jackson-dataformat-yaml 2.16.1",
"src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.16.1/jackson-dataformat-yaml-2.16.1-sources.jar"
"jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.2/jackson-dataformat-yaml-2.18.2.jar",
"name":"jackson-dataformat-yaml 2.18.2",
"src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-yaml/2.18.2/jackson-dataformat-yaml-2.18.2-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.2/snakeyaml-2.2.jar",
"name":"snakeyaml 2.2",
"src":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.2/snakeyaml-2.2-sources.jar"
"jar":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3.jar",
"name":"snakeyaml 2.3",
"src":"https://repo.maven.apache.org/maven2/org/yaml/snakeyaml/2.3/snakeyaml-2.3-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.16.1/jackson-databind-2.16.1.jar",
"name":"jackson-databind 2.16.1",
"src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.16.1/jackson-databind-2.16.1-sources.jar"
"jar":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2.jar",
"name":"jackson-databind 2.18.2",
"src":"https://repo.maven.apache.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.18.2/jackson-databind-2.18.2-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/de/mkammerer/argon2-jvm/2.11/argon2-jvm-2.11.jar",

Binary file not shown.

View File

@ -43,12 +43,38 @@ public class StartHandler extends CommandHandler {
String host = CLIOptionsParser.parseOption("--host", args);
boolean foreground = CLIOptionsParser.hasKey("--foreground", args);
boolean forceNoInMemDB = CLIOptionsParser.hasKey("--no-in-mem-db", args);
boolean javaagentEnabled = CLIOptionsParser.hasKey("--javaagent", args);
boolean jmxEnabled = CLIOptionsParser.hasKey("--jmx", args);
String jmxPort = CLIOptionsParser.parseOption("--jmx-port", args);
String jmxAuthenticate = CLIOptionsParser.parseOption("--jmx-authenticate", args);
String jmxSSL = CLIOptionsParser.parseOption("--jmx-ssl", args);
List<String> commands = new ArrayList<>();
if (OperatingSystem.getOS() == OperatingSystem.OS.WINDOWS) {
commands.add(installationDir + "jre\\bin\\java.exe");
commands.add("-classpath");
commands.add("\"" + installationDir + "core\\*\";\"" + installationDir + "plugin-interface\\*\"");
if (javaagentEnabled) {
commands.add("-javaagent:\"" + installationDir + "agent\\opentelemetry-javaagent.jar\"");
}
if (jmxEnabled) {
commands.add("-Dcom.sun.management.jmxremote");
if (jmxPort != null) {
commands.add("-Dcom.sun.management.jmxremote.port=" + jmxPort);
} else {
commands.add("-Dcom.sun.management.jmxremote.port=9010");
}
if (jmxAuthenticate != null) {
commands.add("-Dcom.sun.management.jmxremote.authenticate=" + jmxAuthenticate);
} else {
commands.add("-Dcom.sun.management.jmxremote.authenticate=false");
}
if (jmxSSL != null) {
commands.add("-Dcom.sun.management.jmxremote.ssl=" + jmxSSL);
} else {
commands.add("-Dcom.sun.management.jmxremote.ssl=false");
}
}
if (space != null) {
commands.add("-Xmx" + space + "M");
}
@ -77,6 +103,27 @@ public class StartHandler extends CommandHandler {
commands.add("-classpath");
commands.add(
installationDir + "core/*:" + installationDir + "plugin-interface/*:" + installationDir + "ee/*");
if (javaagentEnabled) {
commands.add("-javaagent:" + installationDir + "agent/opentelemetry-javaagent.jar");
}
if (jmxEnabled) {
commands.add("-Dcom.sun.management.jmxremote");
if (jmxPort != null) {
commands.add("-Dcom.sun.management.jmxremote.port=" + jmxPort);
} else {
commands.add("-Dcom.sun.management.jmxremote.port=9010");
}
if (jmxAuthenticate != null) {
commands.add("-Dcom.sun.management.jmxremote.authenticate=" + jmxAuthenticate);
} else {
commands.add("-Dcom.sun.management.jmxremote.authenticate=false");
}
if (jmxSSL != null) {
commands.add("-Dcom.sun.management.jmxremote.ssl=" + jmxSSL);
} else {
commands.add("-Dcom.sun.management.jmxremote.ssl=false");
}
}
if (space != null) {
commands.add("-Xmx" + space + "M");
}
@ -101,6 +148,7 @@ public class StartHandler extends CommandHandler {
if (!foreground) {
try {
ProcessBuilder pb = new ProcessBuilder(commands);
Logging.info("Command to be run: " + String.join(" ", pb.command()));
pb.redirectErrorStream(true);
Process process = pb.start();
try (InputStreamReader in = new InputStreamReader(process.getInputStream());
@ -181,6 +229,13 @@ public class StartHandler extends CommandHandler {
new Option("--foreground", "Runs this instance of SuperTokens in the foreground (not as a daemon)"));
options.add(
new Option("--with-temp-dir", "Uses the passed dir as temp dir, instead of the internal default."));
options.add(new Option("--javaagent", "Enables the OpenTelemetry Javaagent for tracing and metrics."));
options.add(new Option("--jmx", "Enables JMX management and monitoring."));
options.add(new Option("--jmx-port", "Sets the port for JMX. Defaults to 9010 if --jmx is passed."));
options.add(new Option("--jmx-authenticate",
"Sets whether JMX authentication is enabled or not. Defaults to false if --jmx is passed."));
options.add(new Option("--jmx-ssl",
"Sets whether JMX SSL is enabled or not. Defaults to false if --jmx is passed."));
return options;
}

View File

@ -183,6 +183,21 @@ core_config_version: 0
# account recovery token is valid for.
# webauthn_recover_account_token_lifetime:
# (OPTIONAL | Default: http://localhost:4317) string value. The URL of the OpenTelemetry collector to which the core
# (OPTIONAL | Default: null) string value. The URL of the OpenTelemetry collector to which the core
# will send telemetry data. This should be in the format http://<host>:<port> or https://<host>:<port>.
# otel_collector_connection_uri:
# (OPTIONAL | Default: false) boolean value. Enables or disables the deadlock logger.
# deadlock_logger_enable:
# (OPTIONAL | Default: null) string value. If specified, uses this URL as ACS URL for handling legacy SAML clients
# saml_legacy_acs_url:
# (OPTIONAL | Default: https://saml.supertokens.com) string value. Service provider's entity ID.
# saml_sp_entity_id:
# OPTIONAL | Default: 300000) long value. Duration for which SAML claims will be valid before it is consumed
# saml_claims_validity:
# OPTIONAL | Default: 300000) long value. Duration for which SAML relay state will be valid before it is consumed
# saml_relay_state_validity:

View File

@ -22,6 +22,7 @@
"5.0",
"5.1",
"5.2",
"5.3"
"5.3",
"5.4"
]
}

View File

@ -183,6 +183,21 @@ disable_telemetry: true
# account recovery token is valid for.
# webauthn_recover_account_token_lifetime:
# (OPTIONAL | Default: http://localhost:4317) string value. The URL of the OpenTelemetry collector to which the core
# (OPTIONAL | Default: null) string value. The URL of the OpenTelemetry collector to which the core
# will send telemetry data. This should be in the format http://<host>:<port> or https://<host>:<port>.
# otel_collector_connection_uri:
# otel_collector_connection_uri:
# (OPTIONAL | Default: false) boolean value. Enables or disables the deadlock logger.
# deadlock_logger_enable:
# (OPTIONAL | Default: null) string value. If specified, uses this URL as ACS URL for handling legacy SAML clients
saml_legacy_acs_url: "http://localhost:5225/api/oauth/saml"
# (OPTIONAL | Default: https://saml.supertokens.com) string value. Service provider's entity ID.
# saml_sp_entity_id:
# OPTIONAL | Default: 300000) long value. Duration for which SAML claims will be valid before it is consumed
# saml_claims_validity:
# OPTIONAL | Default: 300000) long value. Duration for which SAML relay state will be valid before it is consumed
# saml_relay_state_validity:

Binary file not shown.

View File

@ -6,6 +6,8 @@ version = 'unspecified'
repositories {
mavenCentral()
maven { url 'https://build.shibboleth.net/nexus/content/repositories/releases/' }
}
jar {
@ -52,6 +54,7 @@ dependencies {
testImplementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.16.1'
testImplementation group: 'org.jetbrains', name: 'annotations', version: '13.0'
}
tasks.register('copyJars', Copy) {

Binary file not shown.

View File

@ -34,6 +34,7 @@ import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.ThirdPartyConfig;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.session.sqlStorage.SessionSQLStorage;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.utils.Utils;
@ -386,6 +387,34 @@ public class EEFeatureFlag implements io.supertokens.featureflag.EEFeatureFlagIn
return mauArr;
}
private JsonObject getSAMLStats() throws TenantOrAppNotFoundException, StorageQueryException {
JsonObject stats = new JsonObject();
stats.addProperty("connectionUriDomain", this.appIdentifier.getConnectionUriDomain());
stats.addProperty("appId", this.appIdentifier.getAppId());
JsonArray tenantStats = new JsonArray();
TenantConfig[] tenantConfigs = Multitenancy.getAllTenantsForApp(this.appIdentifier, main);
for (TenantConfig tenantConfig : tenantConfigs) {
JsonObject tenantStat = new JsonObject();
tenantStat.addProperty("tenantId", tenantConfig.tenantIdentifier.getTenantId());
{
Storage storage = StorageLayer.getStorage(tenantConfig.tenantIdentifier, main);
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
JsonObject stat = new JsonObject();
stat.addProperty("numberOfSAMLClients", samlStorage.countSAMLClients(tenantConfig.tenantIdentifier));
stat.add(tenantConfig.tenantIdentifier.getTenantId(), stat);
}
}
stats.add("tenants", tenantStats);
return stats;
}
@Override
public JsonObject getPaidFeatureStats() throws StorageQueryException, TenantOrAppNotFoundException {
JsonObject usageStats = new JsonObject();
@ -433,6 +462,10 @@ public class EEFeatureFlag implements io.supertokens.featureflag.EEFeatureFlagIn
if (feature == EE_FEATURES.OAUTH) {
usageStats.add(EE_FEATURES.OAUTH.toString(), getOAuthStats());
}
if (feature == EE_FEATURES.SAML) {
usageStats.add(EE_FEATURES.SAML.toString(), getSAMLStats());
}
}
usageStats.add("maus", getMAUs());
@ -523,7 +556,7 @@ public class EEFeatureFlag implements io.supertokens.featureflag.EEFeatureFlagIn
ProcessState.getInstance(main)
.addState(ProcessState.PROCESS_STATE.LICENSE_KEY_CHECK_NETWORK_CALL, null, json);
JsonObject licenseCheckResponse = HttpRequest.sendJsonPOSTRequest(this.main, REQUEST_ID,
"https://api.supertokens.io/0/st/license/check",
"https://api.supertokens.com/0/st/license/check",
json, 10000, 10000, 0);
if (licenseCheckResponse.get("status").getAsString().equalsIgnoreCase("OK")) {
Logging.debug(main, appIdentifier.getAsPublicTenantIdentifier(), "API returned OK");

View File

@ -2,14 +2,14 @@
"_comment": "Contains list of implementation dependencies URL for this project. This is a generated file, don't modify the contents by hand.",
"list": [
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/tomcat/embed/tomcat-embed-core/11.0.8/tomcat-embed-core-11.0.8.jar",
"name":"tomcat-embed-core 11.0.8",
"src":"https://repo.maven.apache.org/maven2/org/apache/tomcat/embed/tomcat-embed-core/11.0.8/tomcat-embed-core-11.0.8-sources.jar"
"jar":"https://repo.maven.apache.org/maven2/org/apache/tomcat/embed/tomcat-embed-core/11.0.12/tomcat-embed-core-11.0.12.jar",
"name":"tomcat-embed-core 11.0.12",
"src":"https://repo.maven.apache.org/maven2/org/apache/tomcat/embed/tomcat-embed-core/11.0.12/tomcat-embed-core-11.0.12-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/tomcat/tomcat-annotations-api/11.0.8/tomcat-annotations-api-11.0.8.jar",
"name":"tomcat-annotations-api 11.0.8",
"src":"https://repo.maven.apache.org/maven2/org/apache/tomcat/tomcat-annotations-api/11.0.8/tomcat-annotations-api-11.0.8-sources.jar"
"jar":"https://repo.maven.apache.org/maven2/org/apache/tomcat/tomcat-annotations-api/11.0.12/tomcat-annotations-api-11.0.12.jar",
"name":"tomcat-annotations-api 11.0.12",
"src":"https://repo.maven.apache.org/maven2/org/apache/tomcat/tomcat-annotations-api/11.0.12/tomcat-annotations-api-11.0.12-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/google/code/gson/gson/2.13.1/gson-2.13.1.jar",
@ -101,6 +101,146 @@
"name":"webauthn4j-core 0.28.6.RELEASE",
"src":"https://repo.maven.apache.org/maven2/com/webauthn4j/webauthn4j-core/0.28.6.RELEASE/webauthn4j-core-0.28.6.RELEASE-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-core/4.3.1/opensaml-core-4.3.1.jar",
"name":"opensaml-core 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-core/4.3.1/opensaml-core-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/net/shibboleth/utilities/java-support/8.4.1/java-support-8.4.1.jar",
"name":"java-support 8.4.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/net/shibboleth/utilities/java-support/8.4.1/java-support-8.4.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar",
"name":"guava 31.1-jre",
"src":"https://repo.maven.apache.org/maven2/com/google/guava/guava/31.1-jre/guava-31.1-jre-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar",
"name":"failureaccess 1.0.1",
"src":"https://repo.maven.apache.org/maven2/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar",
"name":"listenablefuture 9999.0-empty-to-avoid-conflict-with-guava",
"src":"https://repo.maven.apache.org/maven2/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar",
"name":"j2objc-annotations 1.3",
"src":"https://repo.maven.apache.org/maven2/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/io/dropwizard/metrics/metrics-core/4.2.25/metrics-core-4.2.25.jar",
"name":"metrics-core 4.2.25",
"src":"https://repo.maven.apache.org/maven2/io/dropwizard/metrics/metrics-core/4.2.25/metrics-core-4.2.25-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-saml-impl/4.3.1/opensaml-saml-impl-4.3.1.jar",
"name":"opensaml-saml-impl 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-saml-impl/4.3.1/opensaml-saml-impl-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-xmlsec-impl/4.3.1/opensaml-xmlsec-impl-4.3.1.jar",
"name":"opensaml-xmlsec-impl 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-xmlsec-impl/4.3.1/opensaml-xmlsec-impl-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-security-impl/4.3.1/opensaml-security-impl-4.3.1.jar",
"name":"opensaml-security-impl 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-security-impl/4.3.1/opensaml-security-impl-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-security-api/4.3.1/opensaml-security-api-4.3.1.jar",
"name":"opensaml-security-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-security-api/4.3.1/opensaml-security-api-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-messaging-api/4.3.1/opensaml-messaging-api-4.3.1.jar",
"name":"opensaml-messaging-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-messaging-api/4.3.1/opensaml-messaging-api-4.3.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpclient/4.5.14/httpclient-4.5.14.jar",
"name":"httpclient 4.5.14",
"src":"https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpclient/4.5.14/httpclient-4.5.14-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16.jar",
"name":"httpcore 4.4.16",
"src":"https://repo.maven.apache.org/maven2/org/apache/httpcomponents/httpcore/4.4.16/httpcore-4.4.16-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/cryptacular/cryptacular/1.2.5/cryptacular-1.2.5.jar",
"name":"cryptacular 1.2.5",
"src":"https://repo.maven.apache.org/maven2/org/cryptacular/cryptacular/1.2.5/cryptacular-1.2.5-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.72/bcprov-jdk18on-1.72.jar",
"name":"bcprov-jdk18on 1.72",
"src":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcprov-jdk18on/1.72/bcprov-jdk18on-1.72-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcpkix-jdk18on/1.72/bcpkix-jdk18on-1.72.jar",
"name":"bcpkix-jdk18on 1.72",
"src":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcpkix-jdk18on/1.72/bcpkix-jdk18on-1.72-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcutil-jdk18on/1.72/bcutil-jdk18on-1.72.jar",
"name":"bcutil-jdk18on 1.72",
"src":"https://repo.maven.apache.org/maven2/org/bouncycastle/bcutil-jdk18on/1.72/bcutil-jdk18on-1.72-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-xmlsec-api/4.3.1/opensaml-xmlsec-api-4.3.1.jar",
"name":"opensaml-xmlsec-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-xmlsec-api/4.3.1/opensaml-xmlsec-api-4.3.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/santuario/xmlsec/2.3.4/xmlsec-2.3.4.jar",
"name":"xmlsec 2.3.4",
"src":"https://repo.maven.apache.org/maven2/org/apache/santuario/xmlsec/2.3.4/xmlsec-2.3.4-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-saml-api/4.3.1/opensaml-saml-api-4.3.1.jar",
"name":"opensaml-saml-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-saml-api/4.3.1/opensaml-saml-api-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-profile-api/4.3.1/opensaml-profile-api-4.3.1.jar",
"name":"opensaml-profile-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-profile-api/4.3.1/opensaml-profile-api-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-soap-api/4.3.1/opensaml-soap-api-4.3.1.jar",
"name":"opensaml-soap-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-soap-api/4.3.1/opensaml-soap-api-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-soap-impl/4.3.1/opensaml-soap-impl-4.3.1.jar",
"name":"opensaml-soap-impl 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-soap-impl/4.3.1/opensaml-soap-impl-4.3.1-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-storage-api/4.3.1/opensaml-storage-api-4.3.1.jar",
"name":"opensaml-storage-api 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-storage-api/4.3.1/opensaml-storage-api-4.3.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/velocity/velocity-engine-core/2.3/velocity-engine-core-2.3.jar",
"name":"velocity-engine-core 2.3",
"src":"https://repo.maven.apache.org/maven2/org/apache/velocity/velocity-engine-core/2.3/velocity-engine-core-2.3-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.11/commons-lang3-3.11.jar",
"name":"commons-lang3 3.11",
"src":"https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.11/commons-lang3-3.11-sources.jar"
},
{
"jar":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-profile-impl/4.3.1/opensaml-profile-impl-4.3.1.jar",
"name":"opensaml-profile-impl 4.3.1",
"src":"https://build.shibboleth.net/nexus/content/repositories/releases/org/opensaml/opensaml-profile-impl/4.3.1/opensaml-profile-impl-4.3.1-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-core/1.5.18/logback-core-1.5.18.jar",
"name":"logback-core 1.5.18",
@ -111,6 +251,11 @@
"name":"logback-classic 1.5.18",
"src":"https://repo.maven.apache.org/maven2/ch/qos/logback/logback-classic/1.5.18/logback-classic-1.5.18-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/org/aspectj/aspectjrt/1.9.24/aspectjrt-1.9.24.jar",
"name":"aspectjrt 1.9.24",
"src":"https://repo.maven.apache.org/maven2/org/aspectj/aspectjrt/1.9.24/aspectjrt-1.9.24-sources.jar"
},
{
"jar":"https://repo.maven.apache.org/maven2/io/opentelemetry/opentelemetry-api/1.51.0/opentelemetry-api-1.51.0.jar",
"name":"opentelemetry-api 1.51.0",

Binary file not shown.

BIN
jar/core-11.3.0.jar Normal file

Binary file not shown.

View File

@ -1,6 +1,6 @@
{
"_comment": "contains a list of plugin interfaces branch names that this core supports",
"versions": [
"8.0"
"8.3"
]
}

View File

@ -22,7 +22,9 @@ import io.supertokens.config.CoreConfig;
import io.supertokens.cronjobs.Cronjobs;
import io.supertokens.cronjobs.bulkimport.ProcessBulkImportUsers;
import io.supertokens.cronjobs.cleanupOAuthSessionsAndChallenges.CleanupOAuthSessionsAndChallenges;
import io.supertokens.cronjobs.deleteExpiredSAMLData.DeleteExpiredSAMLData;
import io.supertokens.cronjobs.cleanupWebauthnExpiredData.CleanUpWebauthNExpiredDataCron;
import io.supertokens.cronjobs.deadlocklogger.DeadlockLogger;
import io.supertokens.cronjobs.deleteExpiredAccessTokenSigningKeys.DeleteExpiredAccessTokenSigningKeys;
import io.supertokens.cronjobs.deleteExpiredDashboardSessions.DeleteExpiredDashboardSessions;
import io.supertokens.cronjobs.deleteExpiredEmailVerificationTokens.DeleteExpiredEmailVerificationTokens;
@ -42,6 +44,7 @@ import io.supertokens.pluginInterface.exceptions.DbInitException;
import io.supertokens.pluginInterface.exceptions.InvalidConfigException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.saml.SAMLBootstrap;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.telemetry.TelemetryProvider;
import io.supertokens.version.Version;
@ -157,9 +160,12 @@ public class Main {
// Handle kill signal gracefully
handleKillSignalForWhenItHappens();
StorageLayer.loadStorageUCL(CLIOptions.get(this).getInstallationPath() + "plugin/");
// loading configs for core from config.yaml file.
try {
Config.loadBaseConfig(this);
Logging.info(this, TenantIdentifier.BASE_TENANT, "Completed config.yaml loading.", true);
} catch (InvalidConfigException e) {
throw new QuitProgramException(e);
}
@ -167,14 +173,11 @@ public class Main {
// loading version file
Version.loadVersion(this, CLIOptions.get(this).getInstallationPath() + "version.yaml");
Logging.info(this, TenantIdentifier.BASE_TENANT, "Completed config.yaml loading.", true);
TelemetryProvider.initialize(this);
// loading storage layer
try {
StorageLayer.initPrimary(this, CLIOptions.get(this).getInstallationPath() + "plugin/",
Config.getBaseConfigAsJsonObject(this));
StorageLayer.initPrimary(this, Config.getBaseConfigAsJsonObject(this));
} catch (InvalidConfigException e) {
throw new QuitProgramException(e);
}
@ -182,6 +185,9 @@ public class Main {
// init file logging
Logging.initFileLogging(this);
// Required for SAML related stuff
SAMLBootstrap.initialize();
// initialise cron job handler
Cronjobs.init(this);
@ -278,6 +284,13 @@ public class Main {
Cronjobs.addCronjob(this, CleanUpWebauthNExpiredDataCron.init(this, uniqueUserPoolIdsTenants));
// starts the DeadlockLogger if
if (Config.getBaseConfig(this).isDeadlockLoggerEnabled()) {
DeadlockLogger.getInstance().start();
}
Cronjobs.addCronjob(this, DeleteExpiredSAMLData.init(this, uniqueUserPoolIdsTenants));
// this is to ensure tenantInfos are in sync for the new cron job as well
MultitenancyHelper.getInstance(this).refreshCronjobs();

View File

@ -51,12 +51,12 @@ public class ResourceDistributor {
return appUsedForTesting;
}
public synchronized SingletonResource getResource(AppIdentifier appIdentifier, @Nonnull String key)
public SingletonResource getResource(AppIdentifier appIdentifier, @Nonnull String key)
throws TenantOrAppNotFoundException {
return getResource(appIdentifier.getAsPublicTenantIdentifier(), key);
}
public synchronized SingletonResource getResource(TenantIdentifier tenantIdentifier, @Nonnull String key)
public SingletonResource getResource(TenantIdentifier tenantIdentifier, @Nonnull String key)
throws TenantOrAppNotFoundException {
// first we do exact match
SingletonResource resource = resources.get(new KeyClass(tenantIdentifier, key));
@ -70,14 +70,6 @@ public class ResourceDistributor {
throw new TenantOrAppNotFoundException(tenantIdentifier);
}
MultitenancyHelper.getInstance(main).refreshTenantsInCoreBasedOnChangesInCoreConfigOrIfTenantListChanged(true);
// we try again..
resource = resources.get(new KeyClass(tenantIdentifier, key));
if (resource != null) {
return resource;
}
// then we see if the user has configured anything to do with connectionUriDomain, and if they have,
// then we must return null cause the user has not specifically added tenantId to it
for (KeyClass currKey : resources.keySet()) {
@ -101,11 +93,11 @@ public class ResourceDistributor {
}
@TestOnly
public synchronized SingletonResource getResource(@Nonnull String key) {
public SingletonResource getResource(@Nonnull String key) {
return resources.get(new KeyClass(appUsedForTesting, key));
}
public synchronized SingletonResource setResource(TenantIdentifier tenantIdentifier,
public SingletonResource setResource(TenantIdentifier tenantIdentifier,
@Nonnull String key,
SingletonResource resource) {
SingletonResource alreadyExists = resources.get(new KeyClass(tenantIdentifier, key));
@ -116,7 +108,7 @@ public class ResourceDistributor {
return resource;
}
public synchronized SingletonResource removeResource(TenantIdentifier tenantIdentifier,
public SingletonResource removeResource(TenantIdentifier tenantIdentifier,
@Nonnull String key) {
SingletonResource singletonResource = resources.get(new KeyClass(tenantIdentifier, key));
if (singletonResource == null) {
@ -126,18 +118,18 @@ public class ResourceDistributor {
return singletonResource;
}
public synchronized SingletonResource setResource(AppIdentifier appIdentifier,
public SingletonResource setResource(AppIdentifier appIdentifier,
@Nonnull String key,
SingletonResource resource) {
return setResource(appIdentifier.getAsPublicTenantIdentifier(), key, resource);
}
public synchronized SingletonResource removeResource(AppIdentifier appIdentifier,
public SingletonResource removeResource(AppIdentifier appIdentifier,
@Nonnull String key) {
return removeResource(appIdentifier.getAsPublicTenantIdentifier(), key);
}
public synchronized void clearAllResourcesWithResourceKey(String inputKey) {
public void clearAllResourcesWithResourceKey(String inputKey) {
List<KeyClass> toRemove = new ArrayList<>();
resources.forEach((key, value) -> {
if (key.key.equals(inputKey)) {
@ -149,7 +141,7 @@ public class ResourceDistributor {
}
}
public synchronized Map<KeyClass, SingletonResource> getAllResourcesWithResourceKey(String inputKey) {
public Map<KeyClass, SingletonResource> getAllResourcesWithResourceKey(String inputKey) {
Map<KeyClass, SingletonResource> result = new HashMap<>();
resources.forEach((key, value) -> {
if (key.key.equals(inputKey)) {
@ -160,7 +152,7 @@ public class ResourceDistributor {
}
@TestOnly
public synchronized SingletonResource setResource(@Nonnull String key,
public SingletonResource setResource(@Nonnull String key,
SingletonResource resource) {
return setResource(appUsedForTesting, key, resource);
}

View File

@ -52,6 +52,8 @@ public class Config extends ResourceDistributor.SingletonResource {
final ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
Object configObj = mapper.readValue(new File(configFilePath), Object.class);
JsonObject jsonConfig = new GsonBuilder().serializeNulls().create().toJsonTree(configObj).getAsJsonObject();
CoreConfig.updateConfigJsonFromEnv(jsonConfig);
StorageLayer.updateConfigJsonFromEnv(main, jsonConfig);
CoreConfig config = ConfigMapper.mapConfig(jsonConfig, CoreConfig.class);
config.normalizeAndValidate(main, true);
this.core = config;
@ -91,7 +93,10 @@ public class Config extends ResourceDistributor.SingletonResource {
// omit them from the output json.
ObjectMapper yamlReader = new ObjectMapper(new YAMLFactory());
Object obj = yamlReader.readValue(new File(getConfigFilePath(main)), Object.class);
return new GsonBuilder().serializeNulls().create().toJsonTree(obj).getAsJsonObject();
JsonObject configJson = new GsonBuilder().serializeNulls().create().toJsonTree(obj).getAsJsonObject();
CoreConfig.updateConfigJsonFromEnv(configJson);
StorageLayer.updateConfigJsonFromEnv(main, configJson);
return configJson;
}
private static String getConfigFilePath(Main main) {

View File

@ -67,7 +67,8 @@ public class CoreConfig {
"oauth_provider_public_service_url",
"oauth_provider_admin_service_url",
"oauth_provider_consent_login_base_url",
"oauth_provider_url_configured_in_oauth_provider"
"oauth_provider_url_configured_in_oauth_provider",
"saml_legacy_acs_url"
};
@IgnoreForAnnotationCheck
@ -75,11 +76,13 @@ public class CoreConfig {
@ConfigDescription("The version of the core config.")
private int core_config_version = -1;
@EnvName("ACCESS_TOKEN_VALIDITY")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("Time in seconds for how long an access token is valid for. [Default: 3600 (1 hour)]")
private long access_token_validity = 3600; // in seconds
@EnvName("ACCESS_TOKEN_BLACKLISTING")
@NotConflictingInApp
@JsonProperty
@ConfigDescription(
@ -88,17 +91,20 @@ public class CoreConfig {
"call that requires authentication. (Default: false)")
private boolean access_token_blacklisting = false;
@EnvName("REFRESH_TOKEN_VALIDITY")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("Time in mins for how long a refresh token is valid for. [Default: 60 * 2400 (100 days)]")
private double refresh_token_validity = 60 * 2400; // in mins
@EnvName("PASSWORD_RESET_TOKEN_LIFETIME")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
"Time in milliseconds for how long a password reset token / link is valid for. [Default: 3600000 (1 hour)]")
private long password_reset_token_lifetime = 3600000; // in MS
@EnvName("EMAIL_VERIFICATION_TOKEN_LIFETIME")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
@ -106,23 +112,27 @@ public class CoreConfig {
" 1000 (1 day)]")
private long email_verification_token_lifetime = 24 * 3600 * 1000; // in MS
@EnvName("PASSWORDLESS_MAX_CODE_INPUT_ATTEMPTS")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
"The maximum number of code input attempts per login before the user needs to restart. (Default: 5)")
private int passwordless_max_code_input_attempts = 5;
@EnvName("PASSWORDLESS_CODE_LIFETIME")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
"Time in milliseconds for how long a passwordless code is valid for. [Default: 900000 (15 mins)]")
private long passwordless_code_lifetime = 900000; // in MS
@EnvName("TOTP_MAX_ATTEMPTS")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription("The maximum number of invalid TOTP attempts that will trigger rate limiting. (Default: 5)")
private int totp_max_attempts = 5;
@EnvName("TOTP_RATE_LIMIT_COOLDOWN_SEC")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
@ -133,6 +143,7 @@ public class CoreConfig {
@IgnoreForAnnotationCheck
private final String logDefault = "asdkfahbdfk3kjHS";
@EnvName("INFO_LOG_PATH")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -141,6 +152,7 @@ public class CoreConfig {
"directory/logs/info.log)")
private String info_log_path = logDefault;
@EnvName("ERROR_LOG_PATH")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -149,6 +161,7 @@ public class CoreConfig {
"directory/logs/error.log)")
private String error_log_path = logDefault;
@EnvName("ACCESS_TOKEN_SIGNING_KEY_DYNAMIC")
@NotConflictingInApp
@JsonProperty
@ConfigDescription(
@ -156,17 +169,20 @@ public class CoreConfig {
" be signed using a static signing key. (Default: true)")
private boolean access_token_signing_key_dynamic = true;
@EnvName("ACCESS_TOKEN_DYNAMIC_SIGNING_KEY_UPDATE_INTERVAL")
@NotConflictingInApp
@JsonProperty("access_token_dynamic_signing_key_update_interval")
@JsonAlias({"access_token_dynamic_signing_key_update_interval", "access_token_signing_key_update_interval"})
@ConfigDescription("Time in hours for how frequently the dynamic signing key will change. [Default: 168 (1 week)]")
private double access_token_dynamic_signing_key_update_interval = 168; // in hours
@EnvName("SUPERTOKENS_PORT")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("The port at which SuperTokens service runs. (Default: 3567)")
private int port = 3567;
@EnvName("SUPERTOKENS_HOST")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -174,11 +190,13 @@ public class CoreConfig {
" address associated with your machine. (Default: localhost)")
private String host = "localhost";
@EnvName("MAX_SERVER_POOL_SIZE")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Sets the max thread pool size for incoming http server requests. (Default: 10)")
private int max_server_pool_size = 10;
@EnvName("API_KEYS")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -188,6 +206,7 @@ public class CoreConfig {
"length of 20 chars. (Default: null)")
private String api_keys = null;
@EnvName("DISABLE_TELEMETRY")
@NotConflictingInApp
@JsonProperty
@ConfigDescription(
@ -195,27 +214,32 @@ public class CoreConfig {
"(Default: false)")
private boolean disable_telemetry = false;
@EnvName("PASSWORD_HASHING_ALG")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("The password hashing algorithm to use. Values are \"ARGON2\" | \"BCRYPT\". (Default: BCRYPT)")
@EnumProperty({"ARGON2", "BCRYPT"})
private String password_hashing_alg = "BCRYPT";
@EnvName("ARGON2_ITERATIONS")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Number of iterations for argon2 password hashing. (Default: 1)")
private int argon2_iterations = 1;
@EnvName("ARGON2_MEMORY_KB")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Amount of memory in kb for argon2 password hashing. [Default: 87795 (85 mb)]")
private int argon2_memory_kb = 87795; // 85 mb
@EnvName("ARGON2_PARALLELISM")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Amount of parallelism for argon2 password hashing. (Default: 2)")
private int argon2_parallelism = 2;
@EnvName("ARGON2_HASHING_POOL_SIZE")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -223,6 +247,7 @@ public class CoreConfig {
"(Default: 1)")
private int argon2_hashing_pool_size = 1;
@EnvName("FIREBASE_PASSWORD_HASHING_POOL_SIZE")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -230,6 +255,7 @@ public class CoreConfig {
"(Default: 1)")
private int firebase_password_hashing_pool_size = 1;
@EnvName("BCRYPT_LOG_ROUNDS")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Number of rounds to set for bcrypt password hashing. (Default: 11)")
@ -245,13 +271,16 @@ public class CoreConfig {
// # webserver_https_enabled:
@ConfigYamlOnly
@JsonProperty
@IgnoreForAnnotationCheck
private boolean webserver_https_enabled = false;
@EnvName("BASE_PATH")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription("Used to prepend a base path to all APIs when querying the core.")
private String base_path = "";
@EnvName("LOG_LEVEL")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -260,11 +289,13 @@ public class CoreConfig {
@EnumProperty({"DEBUG", "INFO", "WARN", "ERROR", "NONE"})
private String log_level = "INFO";
@EnvName("FIREBASE_PASSWORD_HASHING_SIGNER_KEY")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("The signer key used for firebase scrypt password hashing. (Default: null)")
private String firebase_password_hashing_signer_key = null;
@EnvName("IP_ALLOW_REGEX")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
@ -272,6 +303,7 @@ public class CoreConfig {
"127\\.\\d+\\.\\d+\\.\\d+|::1|0:0:0:0:0:0:0:1 to allow only localhost to query the core")
private String ip_allow_regex = null;
@EnvName("IP_DENY_REGEX")
@IgnoreForAnnotationCheck
@JsonProperty
@ConfigDescription(
@ -279,6 +311,7 @@ public class CoreConfig {
" address.")
private String ip_deny_regex = null;
@EnvName("OAUTH_PROVIDER_PUBLIC_SERVICE_URL")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -286,6 +319,7 @@ public class CoreConfig {
"If specified, the core uses this URL to connect to the OAuth provider public service.")
private String oauth_provider_public_service_url = null;
@EnvName("OAUTH_PROVIDER_ADMIN_SERVICE_URL")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -293,6 +327,7 @@ public class CoreConfig {
"If specified, the core uses this URL to connect to the OAuth provider admin service.")
private String oauth_provider_admin_service_url = null;
@EnvName("OAUTH_PROVIDER_CONSENT_LOGIN_BASE_URL")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -300,6 +335,7 @@ public class CoreConfig {
"If specified, the core uses this URL to replace the default consent and login URLs to {apiDomain}.")
private String oauth_provider_consent_login_base_url = null;
@EnvName("OAUTH_PROVIDER_URL_CONFIGURED_IN_OAUTH_PROVIDER")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -307,12 +343,14 @@ public class CoreConfig {
"If specified, the core uses this URL to parse responses from the oauth provider when the oauth provider's internal address differs from the known public provider address.")
private String oauth_provider_url_configured_in_oauth_provider = null;
@EnvName("OAUTH_CLIENT_SECRET_ENCRYPTION_KEY")
@ConfigYamlOnly
@JsonProperty
@HideFromDashboard
@ConfigDescription("The encryption key used for saving OAuth client secret on the database.")
private String oauth_client_secret_encryption_key = null;
@EnvName("SUPERTOKENS_SAAS_SECRET")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -322,6 +360,7 @@ public class CoreConfig {
"regular api_keys config.")
private String supertokens_saas_secret = null;
@EnvName("SUPERTOKENS_MAX_CDI_VERSION")
@NotConflictingInApp
@JsonProperty
@HideFromDashboard
@ -331,6 +370,7 @@ public class CoreConfig {
"null)")
private String supertokens_max_cdi_version = null;
@EnvName("SUPERTOKENS_SAAS_LOAD_ONLY_CUD")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
@ -338,36 +378,72 @@ public class CoreConfig {
"the database and block all other CUDs from being used from this instance.")
private String supertokens_saas_load_only_cud = null;
@EnvName("SAML_LEGACY_ACS_URL")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("If specified, uses this URL as ACS URL for handling legacy SAML clients")
@HideFromDashboard
private String saml_legacy_acs_url = null;
@EnvName("SAML_SP_ENTITY_ID")
@JsonProperty
@IgnoreForAnnotationCheck
@ConfigDescription("Service provider's entity ID")
private String saml_sp_entity_id = null;
@EnvName("SAML_CLAIMS_VALIDITY")
@JsonProperty
@IgnoreForAnnotationCheck
@ConfigDescription("Duration for which SAML claims will be valid before it is consumed")
private long saml_claims_validity = 300000;
@EnvName("SAML_RELAY_STATE_VALIDITY")
@JsonProperty
@IgnoreForAnnotationCheck
@ConfigDescription("Duration for which SAML relay state will be valid before it is consumed")
private long saml_relay_state_validity = 300000;
@IgnoreForAnnotationCheck
private Set<LOG_LEVEL> allowedLogLevels = null;
@IgnoreForAnnotationCheck
private boolean isNormalizedAndValid = false;
@EnvName("BULK_MIGRATION_PARALLELISM")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("If specified, the supertokens core will use the specified number of threads to complete the " +
"migration of users. (Default: number of available processor cores).")
private int bulk_migration_parallelism = Runtime.getRuntime().availableProcessors();
@EnvName("BULK_MIGRATION_BATCH_SIZE")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("If specified, the supertokens core will load the specified number of users for migrating in " +
"one single batch. (Default: 8000)")
private int bulk_migration_batch_size = 8000;
@EnvName("WEBAUTHN_RECOVER_ACCOUNT_TOKEN_LIFETIME")
@NotConflictingInApp
@JsonProperty
@ConfigDescription("Time in milliseconds for how long a webauthn account recovery token is valid for. [Default: 3600000 (1 hour)]")
private long webauthn_recover_account_token_lifetime = 3600000; // in MS;
@EnvName("OTEL_COLLECTOR_CONNECTION_URI")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
"The URL of the OpenTelemetry collector to which the core will send telemetry data. " +
"This should be in the format http://<host>:<port> or https://<host>:<port>. (Default: " +
"http://localhost:4317)")
private String otel_collector_connection_uri = "http://localhost:4317";
"null)")
private String otel_collector_connection_uri = null;
@EnvName("DEADLOCK_LOGGER_ENABLE")
@ConfigYamlOnly
@JsonProperty
@ConfigDescription(
"Enables or disables the deadlock logger. (Default: false)")
private boolean deadlock_logger_enable = false;
@IgnoreForAnnotationCheck
private static boolean disableOAuthValidationForTest = false;
@ -437,6 +513,10 @@ public class CoreConfig {
return ip_deny_regex;
}
public String getLogLevel() {
return log_level;
}
public Set<LOG_LEVEL> getLogLevels(Main main) {
if (allowedLogLevels != null) {
return allowedLogLevels;
@ -620,12 +700,72 @@ public class CoreConfig {
return otel_collector_connection_uri;
}
public boolean isDeadlockLoggerEnabled() {
return deadlock_logger_enable;
}
public String getSAMLLegacyACSURL() {
return saml_legacy_acs_url;
}
public String getSAMLSPEntityID() {
return saml_sp_entity_id;
}
public long getSAMLClaimsValidity() {
return saml_claims_validity;
}
public long getSAMLRelayStateValidity() {
return saml_relay_state_validity;
}
private String getConfigFileLocation(Main main) {
return new File(CLIOptions.get(main).getConfigFilePath() == null
? CLIOptions.get(main).getInstallationPath() + "config.yaml"
: CLIOptions.get(main).getConfigFilePath()).getAbsolutePath();
}
public static void updateConfigJsonFromEnv(JsonObject configJson) {
Map<String, String> env = System.getenv();
for (Field field : CoreConfig.class.getDeclaredFields()) {
if (field.isAnnotationPresent(EnvName.class)) {
String envName = field.getAnnotation(EnvName.class).value();
String stringValue = env.get(envName);
if (stringValue == null || stringValue.isEmpty()) {
continue;
}
if (stringValue.startsWith("\"") && stringValue.endsWith("\"")) {
stringValue = stringValue.substring(1, stringValue.length() - 1);
stringValue = stringValue
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\r", "\r")
.replace("\\\"", "\"")
.replace("\\'", "'")
.replace("\\\\", "\\");
}
if (field.getType().equals(String.class)) {
configJson.addProperty(field.getName(), stringValue);
} else if (field.getType().equals(int.class)) {
configJson.addProperty(field.getName(), Integer.parseInt(stringValue));
} else if (field.getType().equals(long.class)) {
configJson.addProperty(field.getName(), Long.parseLong(stringValue));
} else if (field.getType().equals(boolean.class)) {
configJson.addProperty(field.getName(), Boolean.parseBoolean(stringValue));
} else if (field.getType().equals(float.class)) {
configJson.addProperty(field.getName(), Float.parseFloat(stringValue));
} else if (field.getType().equals(double.class)) {
configJson.addProperty(field.getName(), Double.parseDouble(stringValue));
}
}
}
}
void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws InvalidConfigException {
if (isNormalizedAndValid) {
return;
@ -848,6 +988,10 @@ public class CoreConfig {
}
// Normalize
if (saml_sp_entity_id == null) {
saml_sp_entity_id = "https://saml.supertokens.com";
}
if (ip_allow_regex != null) {
ip_allow_regex = ip_allow_regex.trim();
if (ip_allow_regex.equals("")) {

View File

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.config.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
// Make annotation accessible at runtime so that config can be read from env
@Target(ElementType.FIELD) // Annotation can only be applied to fields
public @interface EnvName {
String value(); // String value that provides a env var name for the field
}

View File

@ -0,0 +1,84 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.cronjobs.deadlocklogger;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.Arrays;
public class DeadlockLogger {
private static final DeadlockLogger INSTANCE = new DeadlockLogger();
private DeadlockLogger() {
}
public static DeadlockLogger getInstance() {
return INSTANCE;
}
public void start(){
Thread deadlockLoggerThread = new Thread(deadlockDetector, "DeadlockLoggerThread");
deadlockLoggerThread.setDaemon(true);
deadlockLoggerThread.start();
}
private final Runnable deadlockDetector = new Runnable() {
@Override
public void run() {
System.out.println("DeadlockLogger started!");
while (true) {
System.out.println("DeadlockLogger - checking");
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] threadIds = bean.findDeadlockedThreads(); // Returns null if no threads are deadlocked.
System.out.println("DeadlockLogger - DeadlockedThreads: " + Arrays.toString(threadIds));
if (threadIds != null) {
ThreadInfo[] infos = bean.getThreadInfo(threadIds);
boolean deadlockFound = false;
System.out.println("DEADLOCK found!");
for (ThreadInfo info : infos) {
System.out.println("ThreadName: " + info.getThreadName());
System.out.println("Thread ID: " + info.getThreadId());
System.out.println("LockName: " + info.getLockName());
System.out.println("LockOwnerName: " + info.getLockOwnerName());
System.out.println("LockedMonitors: " + Arrays.toString(info.getLockedMonitors()));
System.out.println("LockInfo: " + info.getLockInfo());
System.out.println("Stack: " + Arrays.toString(info.getStackTrace()));
System.out.println();
deadlockFound = true;
}
System.out.println("*******************************");
if(deadlockFound) {
System.out.println(" ==== ALL THREAD INFO ===");
ThreadInfo[] allThreads = bean.dumpAllThreads(true, true, 100);
for (ThreadInfo threadInfo : allThreads) {
System.out.println("THREAD: " + threadInfo.getThreadName());
System.out.println("StackTrace: " + Arrays.toString(threadInfo.getStackTrace()));
}
break;
}
}
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
};
}

View File

@ -0,0 +1,53 @@
package io.supertokens.cronjobs.deleteExpiredSAMLData;
import java.util.List;
import io.supertokens.Main;
import io.supertokens.cronjobs.CronTask;
import io.supertokens.cronjobs.CronTaskTest;
import io.supertokens.pluginInterface.Storage;
import io.supertokens.pluginInterface.StorageUtils;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.saml.SAMLStorage;
public class DeleteExpiredSAMLData extends CronTask {
public static final String RESOURCE_KEY = "io.supertokens.cronjobs.deleteExpiredSAMLData" +
".DeleteExpiredSAMLData";
private DeleteExpiredSAMLData(Main main, List<List<TenantIdentifier>> tenantsInfo) {
super("DeleteExpiredSAMLData", main, tenantsInfo, false);
}
public static DeleteExpiredSAMLData init(Main main, List<List<TenantIdentifier>> tenantsInfo) {
return (DeleteExpiredSAMLData) main.getResourceDistributor()
.setResource(new TenantIdentifier(null, null, null), RESOURCE_KEY,
new DeleteExpiredSAMLData(main, tenantsInfo));
}
@Override
protected void doTaskPerStorage(Storage storage) throws Exception {
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
samlStorage.removeExpiredSAMLCodesAndRelayStates();
}
@Override
public int getIntervalTimeSeconds() {
if (Main.isTesting) {
Integer interval = CronTaskTest.getInstance(main).getIntervalInSeconds(RESOURCE_KEY);
if (interval != null) {
return interval;
}
}
// Every hour
return 3600;
}
@Override
public int getInitialWaitTimeSeconds() {
if (!Main.isTesting) {
return getIntervalTimeSeconds();
} else {
return 0;
}
}
}

View File

@ -19,12 +19,9 @@ package io.supertokens.cronjobs.syncCoreConfigWithDb;
import io.supertokens.Main;
import io.supertokens.cronjobs.CronTask;
import io.supertokens.cronjobs.CronTaskTest;
import io.supertokens.cronjobs.deleteExpiredSessions.DeleteExpiredSessions;
import io.supertokens.multitenancy.Multitenancy;
import io.supertokens.multitenancy.MultitenancyHelper;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import java.util.List;
import io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan;
public class SyncCoreConfigWithDb extends CronTask {
@ -62,6 +59,7 @@ public class SyncCoreConfigWithDb extends CronTask {
return 60;
}
@WithinOtelSpan
@Override
protected void doTaskForTargetTenant(TenantIdentifier targetTenant) throws Exception {
MultitenancyHelper.getInstance(main).refreshTenantsInCoreBasedOnChangesInCoreConfigOrIfTenantListChanged(true);

View File

@ -157,7 +157,7 @@ public class Telemetry extends CronTask {
json.add("maus", new JsonArray());
}
String url = "https://api.supertokens.io/0/st/telemetry";
String url = "https://api.supertokens.com/0/st/telemetry";
// we call the API only if we are not testing the core, of if the request can be mocked (in case a test
// wants

View File

@ -16,6 +16,16 @@
package io.supertokens.emailpassword;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.List;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.jetbrains.annotations.TestOnly;
import io.supertokens.Main;
import io.supertokens.ResourceDistributor;
import io.supertokens.authRecipe.AuthRecipe;
@ -51,14 +61,6 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoun
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.utils.Utils;
import io.supertokens.webserver.WebserverAPI;
import org.jetbrains.annotations.TestOnly;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.List;
public class EmailPassword {
@ -216,7 +218,7 @@ public class EmailPassword {
public static ImportUserResponse createUserWithPasswordHash(TenantIdentifier tenantIdentifier, Storage storage,
@Nonnull String email,
@Nonnull String passwordHash, @Nullable long timeJoined)
@Nonnull String passwordHash, long timeJoined)
throws StorageQueryException, DuplicateEmailException, TenantOrAppNotFoundException,
StorageTransactionLogicException {
EmailPasswordSQLStorage epStorage = StorageUtils.getEmailPasswordStorage(storage);

View File

@ -18,7 +18,7 @@ package io.supertokens.featureflag;
public enum EE_FEATURES {
ACCOUNT_LINKING("account_linking"), MULTI_TENANCY("multi_tenancy"), TEST("test"),
DASHBOARD_LOGIN("dashboard_login"), MFA("mfa"), SECURITY("security"), OAUTH("oauth");
DASHBOARD_LOGIN("dashboard_login"), MFA("mfa"), SECURITY("security"), OAUTH("oauth"), SAML("saml");
private final String name;

View File

@ -65,11 +65,16 @@ import io.supertokens.pluginInterface.oauth.OAuthLogoutChallenge;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.oauth.exception.DuplicateOAuthLogoutChallengeException;
import io.supertokens.pluginInterface.oauth.exception.OAuthClientNotFoundException;
import io.supertokens.pluginInterface.opentelemetry.OtelProvider;
import io.supertokens.pluginInterface.passwordless.PasswordlessCode;
import io.supertokens.pluginInterface.passwordless.PasswordlessDevice;
import io.supertokens.pluginInterface.passwordless.PasswordlessImportUser;
import io.supertokens.pluginInterface.passwordless.exception.*;
import io.supertokens.pluginInterface.passwordless.sqlStorage.PasswordlessSQLStorage;
import io.supertokens.pluginInterface.saml.SAMLClaimsInfo;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.pluginInterface.saml.SAMLRelayStateInfo;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.session.SessionInfo;
import io.supertokens.pluginInterface.session.SessionStorage;
import io.supertokens.pluginInterface.session.sqlStorage.SessionSQLStorage;
@ -116,7 +121,8 @@ public class Start
implements SessionSQLStorage, EmailPasswordSQLStorage, EmailVerificationSQLStorage, ThirdPartySQLStorage,
JWTRecipeSQLStorage, PasswordlessSQLStorage, UserMetadataSQLStorage, UserRolesSQLStorage, UserIdMappingStorage,
UserIdMappingSQLStorage, MultitenancyStorage, MultitenancySQLStorage, TOTPSQLStorage, ActiveUsersStorage,
ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage, OAuthStorage, WebAuthNSQLStorage {
ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage, OAuthStorage, WebAuthNSQLStorage,
SAMLStorage {
private static final Object appenderLock = new Object();
private static final String ACCESS_TOKEN_SIGNING_KEY_NAME = "access_token_signing_key";
@ -202,7 +208,7 @@ public class Start
}
@Override
public void initFileLogging(String infoLogPath, String errorLogPath) {
public void initFileLogging(String infoLogPath, String errorLogPath, OtelProvider otelProvider) {
// no op
}
@ -227,7 +233,7 @@ public class Start
@Override
public <T> T startTransaction(TransactionLogic<T> logic)
throws StorageTransactionLogicException, StorageQueryException {
return startTransaction(logic, TransactionIsolationLevel.SERIALIZABLE);
return startTransaction(logic, TransactionIsolationLevel.READ_COMMITTED);
}
@Override
@ -619,6 +625,11 @@ public class Start
return true;
}
@Override
public void updateConfigJsonFromEnv(JsonObject configJson) {
// do nothing
}
@Override
public boolean isUserIdBeingUsedInNonAuthRecipe(AppIdentifier appIdentifier, String className, String userId)
throws StorageQueryException {
@ -759,6 +770,8 @@ public class Start
//ignore
} else if (className.equals(OAuthStorage.class.getName())) {
/* Since OAuth tables store client-related data, we don't add user-specific data here */
} else if (className.equals(SAMLStorage.class.getName())) {
// no user specific data here
} else if (className.equals(ActiveUsersStorage.class.getName())) {
try {
ActiveUsersQueries.updateUserLastActive(this, tenantIdentifier.toAppIdentifier(), userId);
@ -3890,4 +3903,72 @@ public class Start
throw new StorageQueryException(e);
}
}
@Override
public SAMLClient createOrUpdateSAMLClient(TenantIdentifier tenantIdentifier, SAMLClient samlClient)
throws StorageQueryException, io.supertokens.pluginInterface.saml.exception.DuplicateEntityIdException {
try {
return SAMLQueries.createOrUpdateSAMLClient(this, tenantIdentifier, samlClient.clientId, samlClient.clientSecret,
samlClient.ssoLoginURL, samlClient.redirectURIs.toString(), samlClient.defaultRedirectURI,
samlClient.idpEntityId, samlClient.idpSigningCertificate, samlClient.allowIDPInitiatedLogin,
samlClient.enableRequestSigning);
} catch (SQLException e) {
String errorMessage = e.getMessage();
String table = io.supertokens.inmemorydb.config.Config.getConfig(this).getSAMLClientsTable();
if (isUniqueConstraintError(errorMessage, table, new String[]{"app_id", "tenant_id", "idp_entity_id"})) {
throw new io.supertokens.pluginInterface.saml.exception.DuplicateEntityIdException();
}
throw new StorageQueryException(e);
}
}
@Override
public boolean removeSAMLClient(TenantIdentifier tenantIdentifier, String clientId) throws StorageQueryException {
return SAMLQueries.removeSAMLClient(this, tenantIdentifier, clientId);
}
@Override
public SAMLClient getSAMLClient(TenantIdentifier tenantIdentifier, String clientId) throws StorageQueryException {
return SAMLQueries.getSAMLClient(this, tenantIdentifier, clientId);
}
@Override
public SAMLClient getSAMLClientByIDPEntityId(TenantIdentifier tenantIdentifier, String idpEntityId) throws StorageQueryException {
return SAMLQueries.getSAMLClientByIDPEntityId(this, tenantIdentifier, idpEntityId);
}
@Override
public List<SAMLClient> getSAMLClients(TenantIdentifier tenantIdentifier) throws StorageQueryException {
return SAMLQueries.getSAMLClients(this, tenantIdentifier);
}
@Override
public void saveRelayStateInfo(TenantIdentifier tenantIdentifier, SAMLRelayStateInfo relayStateInfo, long relayStateValidity) throws StorageQueryException {
SAMLQueries.saveRelayStateInfo(this, tenantIdentifier, relayStateInfo.relayState, relayStateInfo.clientId, relayStateInfo.state, relayStateInfo.redirectURI, relayStateValidity);
}
@Override
public SAMLRelayStateInfo getRelayStateInfo(TenantIdentifier tenantIdentifier, String relayState) throws StorageQueryException {
return SAMLQueries.getRelayStateInfo(this, tenantIdentifier, relayState);
}
@Override
public void saveSAMLClaims(TenantIdentifier tenantIdentifier, String clientId, String code, JsonObject claims, long claimsValidity) throws StorageQueryException {
SAMLQueries.saveSAMLClaims(this, tenantIdentifier, clientId, code, claims.toString(), claimsValidity);
}
@Override
public SAMLClaimsInfo getSAMLClaimsAndRemoveCode(TenantIdentifier tenantIdentifier, String code) throws StorageQueryException {
return SAMLQueries.getSAMLClaimsAndRemoveCode(this, tenantIdentifier, code);
}
@Override
public void removeExpiredSAMLCodesAndRelayStates() throws StorageQueryException {
SAMLQueries.removeExpiredSAMLCodesAndRelayStates(this);
}
@Override
public int countSAMLClients(TenantIdentifier tenantIdentifier) throws StorageQueryException {
return SAMLQueries.countSAMLClients(this, tenantIdentifier);
}
}

View File

@ -194,4 +194,10 @@ public class SQLiteConfig {
public String getWebAuthNCredentialsTable() { return "webauthn_credentials"; }
public String getWebAuthNAccountRecoveryTokenTable() { return "webauthn_account_recovery_tokens"; }
public String getSAMLClientsTable() { return "saml_clients"; }
public String getSAMLRelayStateTable() { return "saml_relay_state"; }
public String getSAMLClaimsTable() { return "saml_claims"; }
}

View File

@ -516,6 +516,33 @@ public class GeneralQueries {
//index
update(start, WebAuthNQueries.getQueryToCreateWebAuthNCredentialsUserIdIndex(start), NO_OP_SETTER);
}
// SAML tables
if (!doesTableExists(start, Config.getConfig(start).getSAMLClientsTable())) {
getInstance(main).addState(CREATING_NEW_TABLE, null);
update(start, SAMLQueries.getQueryToCreateSAMLClientsTable(start), NO_OP_SETTER);
// indexes
update(start, SAMLQueries.getQueryToCreateSAMLClientsAppIdTenantIdIndex(start), NO_OP_SETTER);
}
if (!doesTableExists(start, Config.getConfig(start).getSAMLRelayStateTable())) {
getInstance(main).addState(CREATING_NEW_TABLE, null);
update(start, SAMLQueries.getQueryToCreateSAMLRelayStateTable(start), NO_OP_SETTER);
// indexes
update(start, SAMLQueries.getQueryToCreateSAMLRelayStateAppIdTenantIdIndex(start), NO_OP_SETTER);
update(start, SAMLQueries.getQueryToCreateSAMLRelayStateExpiresAtIndex(start), NO_OP_SETTER);
}
if (!doesTableExists(start, Config.getConfig(start).getSAMLClaimsTable())) {
getInstance(main).addState(CREATING_NEW_TABLE, null);
update(start, SAMLQueries.getQueryToCreateSAMLClaimsTable(start), NO_OP_SETTER);
// indexes
update(start, SAMLQueries.getQueryToCreateSAMLClaimsAppIdTenantIdIndex(start), NO_OP_SETTER);
update(start, SAMLQueries.getQueryToCreateSAMLClaimsExpiresAtIndex(start), NO_OP_SETTER);
}
}
public static void setKeyValue_Transaction(Start start, Connection con, TenantIdentifier tenantIdentifier,

View File

@ -0,0 +1,458 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.inmemorydb.queries;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import static io.supertokens.inmemorydb.QueryExecutorTemplate.execute;
import static io.supertokens.inmemorydb.QueryExecutorTemplate.update;
import io.supertokens.inmemorydb.Start;
import io.supertokens.inmemorydb.config.Config;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.saml.SAMLClaimsInfo;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.pluginInterface.saml.SAMLRelayStateInfo;
public class SAMLQueries {
public static String getQueryToCreateSAMLClientsTable(Start start) {
String table = Config.getConfig(start).getSAMLClientsTable();
String tenantsTable = Config.getConfig(start).getTenantsTable();
// @formatter:off
return "CREATE TABLE IF NOT EXISTS " + table + " ("
+ "app_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "client_id VARCHAR(255) NOT NULL,"
+ "client_secret TEXT,"
+ "sso_login_url TEXT NOT NULL,"
+ "redirect_uris TEXT NOT NULL," // store JsonArray.toString()
+ "default_redirect_uri VARCHAR(1024) NOT NULL,"
+ "idp_entity_id VARCHAR(1024),"
+ "idp_signing_certificate TEXT,"
+ "allow_idp_initiated_login BOOLEAN NOT NULL DEFAULT FALSE,"
+ "enable_request_signing BOOLEAN NOT NULL DEFAULT TRUE,"
+ "created_at BIGINT NOT NULL,"
+ "updated_at BIGINT NOT NULL,"
+ "UNIQUE (app_id, tenant_id, idp_entity_id),"
+ "PRIMARY KEY (app_id, tenant_id, client_id),"
+ "FOREIGN KEY (app_id, tenant_id) REFERENCES " + tenantsTable + " (app_id, tenant_id) ON DELETE CASCADE"
+ ");";
// @formatter:on
}
public static String getQueryToCreateSAMLClientsAppIdTenantIdIndex(Start start) {
String table = Config.getConfig(start).getSAMLClientsTable();
return "CREATE INDEX IF NOT EXISTS saml_clients_app_tenant_index ON " + table + "(app_id, tenant_id);";
}
public static String getQueryToCreateSAMLRelayStateTable(Start start) {
String table = Config.getConfig(start).getSAMLRelayStateTable();
String tenantsTable = Config.getConfig(start).getTenantsTable();
// @formatter:off
return "CREATE TABLE IF NOT EXISTS " + table + " ("
+ "app_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "relay_state VARCHAR(255) NOT NULL,"
+ "client_id VARCHAR(255) NOT NULL,"
+ "state TEXT,"
+ "redirect_uri VARCHAR(1024) NOT NULL,"
+ "created_at BIGINT NOT NULL,"
+ "expires_at BIGINT NOT NULL,"
+ "PRIMARY KEY (relay_state)," // relayState must be unique
+ "FOREIGN KEY (app_id, tenant_id) REFERENCES " + tenantsTable + " (app_id, tenant_id) ON DELETE CASCADE"
+ ");";
// @formatter:on
}
public static String getQueryToCreateSAMLRelayStateAppIdTenantIdIndex(Start start) {
String table = Config.getConfig(start).getSAMLRelayStateTable();
return "CREATE INDEX IF NOT EXISTS saml_relay_state_app_tenant_index ON " + table + "(app_id, tenant_id);";
}
public static String getQueryToCreateSAMLRelayStateExpiresAtIndex(Start start) {
String table = Config.getConfig(start).getSAMLRelayStateTable();
return "CREATE INDEX IF NOT EXISTS saml_relay_state_expires_at_index ON " + table + "(expires_at);";
}
public static String getQueryToCreateSAMLClaimsTable(Start start) {
String table = Config.getConfig(start).getSAMLClaimsTable();
String tenantsTable = Config.getConfig(start).getTenantsTable();
// @formatter:off
return "CREATE TABLE IF NOT EXISTS " + table + " ("
+ "app_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "tenant_id VARCHAR(64) NOT NULL DEFAULT 'public',"
+ "client_id VARCHAR(255) NOT NULL,"
+ "code VARCHAR(255) NOT NULL,"
+ "claims TEXT NOT NULL,"
+ "created_at BIGINT NOT NULL,"
+ "expires_at BIGINT NOT NULL,"
+ "PRIMARY KEY (code),"
+ "FOREIGN KEY (app_id, tenant_id) REFERENCES " + tenantsTable + " (app_id, tenant_id) ON DELETE CASCADE"
+ ");";
// @formatter:on
}
public static String getQueryToCreateSAMLClaimsAppIdTenantIdIndex(Start start) {
String table = Config.getConfig(start).getSAMLClaimsTable();
return "CREATE INDEX IF NOT EXISTS saml_claims_app_tenant_index ON " + table + "(app_id, tenant_id);";
}
public static String getQueryToCreateSAMLClaimsExpiresAtIndex(Start start) {
String table = Config.getConfig(start).getSAMLClaimsTable();
return "CREATE INDEX IF NOT EXISTS saml_claims_expires_at_index ON " + table + "(expires_at);";
}
public static void saveRelayStateInfo(Start start, TenantIdentifier tenantIdentifier,
String relayState, String clientId, String state, String redirectURI, long relayStateValidity)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLRelayStateTable();
String QUERY = "INSERT INTO " + table +
" (app_id, tenant_id, relay_state, client_id, state, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
try {
update(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, relayState);
pst.setString(4, clientId);
if (state != null) {
pst.setString(5, state);
} else {
pst.setNull(5, java.sql.Types.VARCHAR);
}
pst.setString(6, redirectURI);
pst.setLong(7, System.currentTimeMillis());
pst.setLong(8, System.currentTimeMillis() + relayStateValidity);
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static SAMLRelayStateInfo getRelayStateInfo(Start start, TenantIdentifier tenantIdentifier, String relayState)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLRelayStateTable();
String QUERY = "SELECT client_id, state, redirect_uri, expires_at FROM " + table
+ " WHERE app_id = ? AND tenant_id = ? AND relay_state = ? AND expires_at >= ?";
try {
return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, relayState);
pst.setLong(4, System.currentTimeMillis());
}, result -> {
if (result.next()) {
String clientId = result.getString("client_id");
String state = result.getString("state"); // may be null
String redirectURI = result.getString("redirect_uri");
return new SAMLRelayStateInfo(relayState, clientId, state, redirectURI);
}
return null;
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static void saveSAMLClaims(Start start, TenantIdentifier tenantIdentifier, String clientId, String code, String claimsJson, long claimsValidity)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClaimsTable();
String QUERY = "INSERT INTO " + table +
" (app_id, tenant_id, client_id, code, claims, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
try {
update(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, clientId);
pst.setString(4, code);
pst.setString(5, claimsJson);
pst.setLong(6, System.currentTimeMillis());
pst.setLong(7, System.currentTimeMillis() + claimsValidity);
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static SAMLClaimsInfo getSAMLClaimsAndRemoveCode(Start start, TenantIdentifier tenantIdentifier, String code)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClaimsTable();
String QUERY = "SELECT client_id, claims FROM " + table + " WHERE app_id = ? AND tenant_id = ? AND code = ? AND expires_at >= ?";
try {
SAMLClaimsInfo claimsInfo = execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, code);
pst.setLong(4, System.currentTimeMillis());
}, result -> {
if (result.next()) {
String clientId = result.getString("client_id");
JsonObject claims = com.google.gson.JsonParser.parseString(result.getString("claims")).getAsJsonObject();
return new SAMLClaimsInfo(clientId, claims);
}
return null;
});
if (claimsInfo != null) {
String DELETE = "DELETE FROM " + table + " WHERE app_id = ? AND tenant_id = ? AND code = ?";
update(start, DELETE, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, code);
});
}
return claimsInfo;
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static SAMLClient createOrUpdateSAMLClient(
Start start,
TenantIdentifier tenantIdentifier,
String clientId,
String clientSecret,
String ssoLoginURL,
String redirectURIsJson,
String defaultRedirectURI,
String idpEntityId,
String idpSigningCertificate,
boolean allowIDPInitiatedLogin,
boolean enableRequestSigning)
throws StorageQueryException, SQLException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "INSERT INTO " + table +
" (app_id, tenant_id, client_id, client_secret, sso_login_url, redirect_uris, default_redirect_uri, idp_entity_id, idp_signing_certificate, allow_idp_initiated_login, enable_request_signing, created_at, updated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (app_id, tenant_id, client_id) DO UPDATE SET " +
"client_secret = ?, sso_login_url = ?, redirect_uris = ?, default_redirect_uri = ?, idp_entity_id = ?, idp_signing_certificate = ?, allow_idp_initiated_login = ?, enable_request_signing = ?, updated_at = ?";
long now = System.currentTimeMillis();
update(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, clientId);
if (clientSecret != null) {
pst.setString(4, clientSecret);
} else {
pst.setNull(4, Types.VARCHAR);
}
pst.setString(5, ssoLoginURL);
pst.setString(6, redirectURIsJson);
pst.setString(7, defaultRedirectURI);
if (idpEntityId != null) {
pst.setString(8, idpEntityId);
} else {
pst.setNull(8, java.sql.Types.VARCHAR);
}
if (idpSigningCertificate != null) {
pst.setString(9, idpSigningCertificate);
} else {
pst.setNull(9, Types.VARCHAR);
}
pst.setBoolean(10, allowIDPInitiatedLogin);
pst.setBoolean(11, enableRequestSigning);
pst.setLong(12, now);
pst.setLong(13, now);
if (clientSecret != null) {
pst.setString(14, clientSecret);
} else {
pst.setNull(14, Types.VARCHAR);
}
pst.setString(15, ssoLoginURL);
pst.setString(16, redirectURIsJson);
pst.setString(17, defaultRedirectURI);
if (idpEntityId != null) {
pst.setString(18, idpEntityId);
} else {
pst.setNull(18, java.sql.Types.VARCHAR);
}
if (idpSigningCertificate != null) {
pst.setString(19, idpSigningCertificate);
} else {
pst.setNull(19, Types.VARCHAR);
}
pst.setBoolean(20, allowIDPInitiatedLogin);
pst.setBoolean(21, enableRequestSigning);
pst.setLong(22, now);
});
return getSAMLClient(start, tenantIdentifier, clientId);
}
public static SAMLClient getSAMLClient(Start start, TenantIdentifier tenantIdentifier, String clientId)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "SELECT client_id, client_secret, sso_login_url, redirect_uris, default_redirect_uri, idp_entity_id, idp_signing_certificate, allow_idp_initiated_login, enable_request_signing FROM " + table
+ " WHERE app_id = ? AND tenant_id = ? AND client_id = ?";
try {
return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, clientId);
}, result -> {
if (result.next()) {
String fetchedClientId = result.getString("client_id");
String clientSecret = result.getString("client_secret");
String ssoLoginURL = result.getString("sso_login_url");
String redirectUrisJson = result.getString("redirect_uris");
String defaultRedirectURI = result.getString("default_redirect_uri");
String idpEntityId = result.getString("idp_entity_id");
String idpSigningCertificate = result.getString("idp_signing_certificate");
boolean allowIDPInitiatedLogin = result.getBoolean("allow_idp_initiated_login");
boolean enableRequestSigning = result.getBoolean("enable_request_signing");
JsonArray redirectURIs = JsonParser.parseString(redirectUrisJson).getAsJsonArray();
return new SAMLClient(fetchedClientId, clientSecret, ssoLoginURL, redirectURIs, defaultRedirectURI, idpEntityId, idpSigningCertificate, allowIDPInitiatedLogin, enableRequestSigning);
}
return null;
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static SAMLClient getSAMLClientByIDPEntityId(Start start, TenantIdentifier tenantIdentifier, String idpEntityId) throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "SELECT client_id, client_secret, sso_login_url, redirect_uris, default_redirect_uri, idp_entity_id, idp_signing_certificate, allow_idp_initiated_login, enable_request_signing FROM " + table
+ " WHERE app_id = ? AND tenant_id = ? AND idp_entity_id = ?";
try {
return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, idpEntityId);
}, result -> {
if (result.next()) {
String fetchedClientId = result.getString("client_id");
String clientSecret = result.getString("client_secret");
String ssoLoginURL = result.getString("sso_login_url");
String redirectUrisJson = result.getString("redirect_uris");
String defaultRedirectURI = result.getString("default_redirect_uri");
String fetchedIdpEntityId = result.getString("idp_entity_id");
String idpSigningCertificate = result.getString("idp_signing_certificate");
boolean allowIDPInitiatedLogin = result.getBoolean("allow_idp_initiated_login");
boolean enableRequestSigning = result.getBoolean("enable_request_signing");
JsonArray redirectURIs = JsonParser.parseString(redirectUrisJson).getAsJsonArray();
return new SAMLClient(fetchedClientId, clientSecret, ssoLoginURL, redirectURIs, defaultRedirectURI, fetchedIdpEntityId, idpSigningCertificate, allowIDPInitiatedLogin, enableRequestSigning);
}
return null;
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static List<SAMLClient> getSAMLClients(Start start, TenantIdentifier tenantIdentifier)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "SELECT client_id, client_secret, sso_login_url, redirect_uris, default_redirect_uri, idp_entity_id, idp_signing_certificate, allow_idp_initiated_login, enable_request_signing FROM " + table
+ " WHERE app_id = ? AND tenant_id = ?";
try {
return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
}, result -> {
List<SAMLClient> clients = new ArrayList<>();
while (result.next()) {
String fetchedClientId = result.getString("client_id");
String clientSecret = result.getString("client_secret");
String ssoLoginURL = result.getString("sso_login_url");
String redirectUrisJson = result.getString("redirect_uris");
String defaultRedirectURI = result.getString("default_redirect_uri");
String idpEntityId = result.getString("idp_entity_id");
String idpSigningCertificate = result.getString("idp_signing_certificate");
boolean allowIDPInitiatedLogin = result.getBoolean("allow_idp_initiated_login");
boolean enableRequestSigning = result.getBoolean("enable_request_signing");
JsonArray redirectURIs = JsonParser.parseString(redirectUrisJson).getAsJsonArray();
clients.add(new SAMLClient(fetchedClientId, clientSecret, ssoLoginURL, redirectURIs, defaultRedirectURI, idpEntityId, idpSigningCertificate, allowIDPInitiatedLogin, enableRequestSigning));
}
return clients;
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static boolean removeSAMLClient(Start start, TenantIdentifier tenantIdentifier, String clientId)
throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "DELETE FROM " + table + " WHERE app_id = ? AND tenant_id = ? AND client_id = ?";
try {
return update(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
pst.setString(3, clientId);
}) > 0;
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static void removeExpiredSAMLCodesAndRelayStates(Start start) throws StorageQueryException {
try {
{
String QUERY = "DELETE FROM " + Config.getConfig(start).getSAMLClaimsTable() + " WHERE expires_at <= ?";
update(start, QUERY, pst -> {
pst.setLong(1, System.currentTimeMillis());
});
}
{
String QUERY = "DELETE FROM " + Config.getConfig(start).getSAMLRelayStateTable() + " WHERE expires_at <= ?";
update(start, QUERY, pst -> {
pst.setLong(1, System.currentTimeMillis());
});
}
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
public static int countSAMLClients(Start start, TenantIdentifier tenantIdentifier) throws StorageQueryException {
String table = Config.getConfig(start).getSAMLClientsTable();
String QUERY = "SELECT COUNT(*) as c FROM " + table
+ " WHERE app_id = ? AND tenant_id = ?";
try {
return execute(start, QUERY, pst -> {
pst.setString(1, tenantIdentifier.getAppId());
pst.setString(2, tenantIdentifier.getTenantId());
}, result -> {
if (result.next()) {
return result.getInt("c");
}
return 0;
});
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
}

View File

@ -33,6 +33,8 @@ import io.supertokens.pluginInterface.exceptions.InvalidConfigException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.*;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAMLCertificate;
import io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan;
import io.supertokens.session.refreshToken.RefreshTokenKey;
import io.supertokens.signingkeys.AccessTokenSigningKey;
import io.supertokens.signingkeys.JWTSigningKey;
@ -116,6 +118,7 @@ public class MultitenancyHelper extends ResourceDistributor.SingletonResource {
return StorageLayer.getMultitenancyStorage(main).getAllTenants();
}
@WithinOtelSpan
public List<TenantIdentifier> refreshTenantsInCoreBasedOnChangesInCoreConfigOrIfTenantListChanged(
boolean reloadAllResources) {
try {
@ -233,6 +236,7 @@ public class MultitenancyHelper extends ResourceDistributor.SingletonResource {
}
AccessTokenSigningKey.loadForAllTenants(main, apps, tenantsThatChanged);
RefreshTokenKey.loadForAllTenants(main, apps, tenantsThatChanged);
SAMLCertificate.loadForAllTenants(main, apps, tenantsThatChanged);
JWTSigningKey.loadForAllTenants(main, apps, tenantsThatChanged);
SigningKeys.loadForAllTenants(main, apps, tenantsThatChanged);
}

View File

@ -16,6 +16,7 @@
package io.supertokens.output;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.spi.ILoggingEvent;
@ -55,6 +56,12 @@ public class Logging extends ResourceDistributor.SingletonResource {
public static final String ANSI_WHITE = "\u001B[37m";
private Logging(Main main) {
// Set global logging level
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);
Level newLevel = Level.toLevel(Config.getBaseConfig(main).getLogLevel(), Level.INFO); // Default to INFO if invalid
rootLogger.setLevel(newLevel);
this.infoLogger = Config.getBaseConfig(main).getInfoLogPath(main).equals("null")
? createLoggerForConsole(main, "io.supertokens.Info", LOG_LEVEL.INFO)
: createLoggerForFile(main, Config.getBaseConfig(main).getInfoLogPath(main),
@ -66,7 +73,7 @@ public class Logging extends ResourceDistributor.SingletonResource {
Storage storage = StorageLayer.getBaseStorage(main);
if (storage != null) {
storage.initFileLogging(Config.getBaseConfig(main).getInfoLogPath(main),
Config.getBaseConfig(main).getErrorLogPath(main));
Config.getBaseConfig(main).getErrorLogPath(main), TelemetryProvider.getInstance(main));
}
try {
// we wait here for a bit so that the loggers can be properly initialised..
@ -114,7 +121,7 @@ public class Logging extends ResourceDistributor.SingletonResource {
if (getInstance(main) != null) {
String formattedMsg = getFormattedMessage(tenantIdentifier, msg);
getInstance(main).infoLogger.debug(formattedMsg);
TelemetryProvider.createLogEvent(main, tenantIdentifier, formattedMsg, "debug");
TelemetryProvider.getInstance(main).createLogEvent(tenantIdentifier, formattedMsg, "debug");
}
} catch (NullPointerException e) {
// sometimes logger.debug throws a null pointer exception...
@ -166,7 +173,7 @@ public class Logging extends ResourceDistributor.SingletonResource {
getInstance(main).infoLogger.info(msg);
}
TelemetryProvider.createLogEvent(main, tenantIdentifier, msg, "info");
TelemetryProvider.getInstance(main).createLogEvent(tenantIdentifier, msg, "info");
} catch (NullPointerException ignored) {
}
}
@ -180,7 +187,7 @@ public class Logging extends ResourceDistributor.SingletonResource {
msg = getFormattedMessage(tenantIdentifier, msg);
if (getInstance(main) != null) {
getInstance(main).errorLogger.warn(msg);
TelemetryProvider.createLogEvent(main, tenantIdentifier, msg, "warn");
TelemetryProvider.getInstance(main).createLogEvent(tenantIdentifier, msg, "warn");
}
} catch (NullPointerException ignored) {
}
@ -202,7 +209,7 @@ public class Logging extends ResourceDistributor.SingletonResource {
if (getInstance(main) != null) {
String formattedMessage = getFormattedMessage(tenantIdentifier, err);
getInstance(main).errorLogger.error(formattedMessage);
TelemetryProvider.createLogEvent(main, tenantIdentifier, formattedMessage, "error");
TelemetryProvider.getInstance(main).createLogEvent(tenantIdentifier, formattedMessage, "error");
}
if (toConsoleAsWell || getInstance(main) == null) {
systemErr(prependTenantIdentifierToMessage(tenantIdentifier, err));
@ -236,8 +243,8 @@ public class Logging extends ResourceDistributor.SingletonResource {
message = message.trim();
if (getInstance(main) != null) {
getInstance(main).errorLogger.error(getFormattedMessage(tenantIdentifier, message, e));
TelemetryProvider
.createLogEvent(main, tenantIdentifier, getFormattedMessage(tenantIdentifier, message, e),
TelemetryProvider.getInstance(main)
.createLogEvent(tenantIdentifier, getFormattedMessage(tenantIdentifier, message, e),
"error");
}
if (toConsoleAsWell || getInstance(main) == null) {

View File

@ -0,0 +1,690 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.saml;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.XMLObjectBuilderFactory;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.io.UnmarshallingException;
import org.opensaml.core.xml.util.XMLObjectSupport;
import org.opensaml.saml.common.SAMLVersion;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AttributeStatement;
import org.opensaml.saml.saml2.core.Audience;
import org.opensaml.saml.saml2.core.AudienceRestriction;
import org.opensaml.saml.saml2.core.AuthnContext;
import org.opensaml.saml.saml2.core.AuthnContextClassRef;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.core.Conditions;
import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.NameIDPolicy;
import org.opensaml.saml.saml2.core.RequestedAuthnContext;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.core.Subject;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.IDPSSODescriptor;
import org.opensaml.saml.saml2.metadata.SingleSignOnService;
import org.opensaml.security.credential.Credential;
import org.opensaml.security.credential.CredentialSupport;
import org.opensaml.xmlsec.signature.KeyInfo;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.X509Data;
import org.opensaml.xmlsec.signature.impl.KeyInfoBuilder;
import org.opensaml.xmlsec.signature.impl.SignatureBuilder;
import org.opensaml.xmlsec.signature.impl.X509DataBuilder;
import org.opensaml.xmlsec.signature.support.SignatureConstants;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.opensaml.xmlsec.signature.support.SignatureValidator;
import org.w3c.dom.Element;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.config.Config;
import io.supertokens.config.CoreConfig;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlag;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.pluginInterface.Storage;
import io.supertokens.pluginInterface.StorageUtils;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.saml.SAMLClaimsInfo;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.pluginInterface.saml.SAMLRelayStateInfo;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.saml.exception.DuplicateEntityIdException;
import io.supertokens.saml.exceptions.IDPInitiatedLoginDisallowedException;
import io.supertokens.saml.exceptions.InvalidClientException;
import io.supertokens.saml.exceptions.InvalidCodeException;
import io.supertokens.saml.exceptions.InvalidRelayStateException;
import io.supertokens.saml.exceptions.MalformedSAMLMetadataXMLException;
import io.supertokens.saml.exceptions.SAMLResponseVerificationFailedException;
import net.shibboleth.utilities.java.support.xml.SerializeSupport;
import net.shibboleth.utilities.java.support.xml.XMLParserException;
public class SAML {
public static void checkForSAMLFeature(AppIdentifier appIdentifier, Main main)
throws StorageQueryException, TenantOrAppNotFoundException, FeatureNotEnabledException {
EE_FEATURES[] features = FeatureFlag.getInstance(main, appIdentifier).getEnabledFeatures();
for (EE_FEATURES f : features) {
if (f == EE_FEATURES.SAML) {
return;
}
}
throw new FeatureNotEnabledException(
"SAML feature is not enabled. Please subscribe to a SuperTokens core license key to enable this " +
"feature.");
}
public static SAMLClient createOrUpdateSAMLClient(
Main main, TenantIdentifier tenantIdentifier, Storage storage,
String clientId, String clientSecret, String defaultRedirectURI, JsonArray redirectURIs, String metadataXML, boolean allowIDPInitiatedLogin, boolean enableRequestSigning)
throws MalformedSAMLMetadataXMLException, StorageQueryException, CertificateException,
FeatureNotEnabledException, TenantOrAppNotFoundException, DuplicateEntityIdException {
checkForSAMLFeature(tenantIdentifier.toAppIdentifier(), main);
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
var metadata = loadIdpMetadata(metadataXML);
String idpSsoUrl = null;
for (var roleDescriptor : metadata.getRoleDescriptors()) {
if (roleDescriptor instanceof IDPSSODescriptor) {
IDPSSODescriptor idpDescriptor = (IDPSSODescriptor) roleDescriptor;
for (SingleSignOnService ssoService : idpDescriptor.getSingleSignOnServices()) {
if (SAMLConstants.SAML2_REDIRECT_BINDING_URI.equals(ssoService.getBinding())) {
idpSsoUrl = ssoService.getLocation();
}
}
}
}
if (idpSsoUrl == null) {
throw new MalformedSAMLMetadataXMLException();
}
String idpSigningCertificate = extractIdpSigningCertificate(metadata);
getCertificateFromString(idpSigningCertificate); // checking validity
String idpEntityId = metadata.getEntityID();
SAMLClient client = new SAMLClient(clientId, clientSecret, idpSsoUrl, redirectURIs, defaultRedirectURI, idpEntityId, idpSigningCertificate, allowIDPInitiatedLogin, enableRequestSigning);
return samlStorage.createOrUpdateSAMLClient(tenantIdentifier, client);
}
public static List<SAMLClient> getClients(TenantIdentifier tenantIdentifier, Storage storage) throws StorageQueryException {
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
return samlStorage.getSAMLClients(tenantIdentifier);
}
public static SAMLClient getClient(TenantIdentifier tenantIdentifier, Storage storage, String clientId) throws StorageQueryException {
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
return samlStorage.getSAMLClient(tenantIdentifier, clientId);
}
public static boolean removeSAMLClient(TenantIdentifier tenantIdentifier, Storage storage, String clientId) throws StorageQueryException {
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
return samlStorage.removeSAMLClient(tenantIdentifier, clientId);
}
private static String extractIdpSigningCertificate(EntityDescriptor idpMetadata) {
for (var roleDescriptor : idpMetadata.getRoleDescriptors()) {
if (roleDescriptor instanceof IDPSSODescriptor) {
IDPSSODescriptor idpDescriptor = (IDPSSODescriptor) roleDescriptor;
for (org.opensaml.saml.saml2.metadata.KeyDescriptor keyDescriptor : idpDescriptor.getKeyDescriptors()) {
if (keyDescriptor.getUse() == null ||
"SIGNING".equals(keyDescriptor.getUse().toString())) {
org.opensaml.xmlsec.signature.KeyInfo keyInfo = keyDescriptor.getKeyInfo();
if (keyInfo != null) {
for (org.opensaml.xmlsec.signature.X509Data x509Data : keyInfo.getX509Datas()) {
for (org.opensaml.xmlsec.signature.X509Certificate x509Cert : x509Data.getX509Certificates()) {
try {
String certString = x509Cert.getValue();
if (certString != null && !certString.trim().isEmpty()) {
certString = certString.replaceAll("\\s", "");
return certString;
}
} catch (Exception e) {
// Continue to next certificate if this one fails
continue;
}
}
}
}
}
}
}
}
return null;
}
public static String createRedirectURL(Main main, TenantIdentifier tenantIdentifier, Storage storage,
String clientId, String redirectURI, String state, String acsURL)
throws StorageQueryException, InvalidClientException, TenantOrAppNotFoundException,
CertificateEncodingException, FeatureNotEnabledException {
checkForSAMLFeature(tenantIdentifier.toAppIdentifier(), main);
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
CoreConfig config = Config.getConfig(tenantIdentifier, main);
SAMLClient client = samlStorage.getSAMLClient(tenantIdentifier, clientId);
if (client == null) {
throw new InvalidClientException();
}
boolean redirectURIOk = false;
for (JsonElement rUri : client.redirectURIs) {
if (rUri.getAsString().equals(redirectURI)) {
redirectURIOk = true;
break;
}
}
if (!redirectURIOk) {
throw new InvalidClientException();
}
String idpSsoUrl = client.ssoLoginURL;
AuthnRequest request = buildAuthnRequest(
main,
tenantIdentifier.toAppIdentifier(),
idpSsoUrl,
config.getSAMLSPEntityID(), acsURL,
client.enableRequestSigning);
String samlRequest = deflateAndBase64RedirectMessage(request);
String relayState = UUID.randomUUID().toString();
samlStorage.saveRelayStateInfo(tenantIdentifier, new SAMLRelayStateInfo(relayState, clientId, state, redirectURI), config.getSAMLRelayStateValidity());
return idpSsoUrl + "?SAMLRequest=" + samlRequest + "&RelayState=" + URLEncoder.encode(relayState, StandardCharsets.UTF_8);
}
public static EntityDescriptor loadIdpMetadata(String metadataXML) throws MalformedSAMLMetadataXMLException {
try {
byte[] bytes = metadataXML.getBytes(StandardCharsets.UTF_8);
try (InputStream inputStream = new java.io.ByteArrayInputStream(bytes)) {
XMLObject xmlObject = XMLObjectSupport.unmarshallFromInputStream(
XMLObjectProviderRegistrySupport.getParserPool(), inputStream);
if (xmlObject instanceof EntityDescriptor) {
return (EntityDescriptor) xmlObject;
} else {
throw new RuntimeException("Expected EntityDescriptor but got: " + xmlObject.getClass());
}
}
} catch (Exception e) {
throw new MalformedSAMLMetadataXMLException();
}
}
private static AuthnRequest buildAuthnRequest(Main main, AppIdentifier appIdentifier, String idpSsoUrl, String spEntityId, String acsUrl, boolean enableRequestSigning)
throws TenantOrAppNotFoundException, StorageQueryException, CertificateEncodingException {
XMLObjectBuilderFactory builders = XMLObjectProviderRegistrySupport.getBuilderFactory();
AuthnRequest authnRequest = (AuthnRequest) builders
.<AuthnRequest>getBuilder(AuthnRequest.DEFAULT_ELEMENT_NAME)
.buildObject(AuthnRequest.DEFAULT_ELEMENT_NAME);
authnRequest.setID("_" + UUID.randomUUID());
authnRequest.setIssueInstant(Instant.now());
authnRequest.setVersion(SAMLVersion.VERSION_20);
authnRequest.setDestination(idpSsoUrl);
authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
Issuer issuer = (Issuer) builders.getBuilder(Issuer.DEFAULT_ELEMENT_NAME)
.buildObject(Issuer.DEFAULT_ELEMENT_NAME);
issuer.setValue(spEntityId);
authnRequest.setIssuer(issuer);
NameIDPolicy nameIDPolicy = (NameIDPolicy) builders.getBuilder(NameIDPolicy.DEFAULT_ELEMENT_NAME)
.buildObject(NameIDPolicy.DEFAULT_ELEMENT_NAME);
nameIDPolicy.setAllowCreate(true);
authnRequest.setNameIDPolicy(nameIDPolicy);
RequestedAuthnContext rac = (RequestedAuthnContext) builders.getBuilder(RequestedAuthnContext.DEFAULT_ELEMENT_NAME)
.buildObject(RequestedAuthnContext.DEFAULT_ELEMENT_NAME);
rac.setComparison(org.opensaml.saml.saml2.core.AuthnContextComparisonTypeEnumeration.EXACT);
AuthnContextClassRef classRef = (AuthnContextClassRef) builders.getBuilder(AuthnContextClassRef.DEFAULT_ELEMENT_NAME)
.buildObject(AuthnContextClassRef.DEFAULT_ELEMENT_NAME);
classRef.setURI(AuthnContext.PASSWORD_AUTHN_CTX);
rac.getAuthnContextClassRefs().add(classRef);
authnRequest.setRequestedAuthnContext(rac);
authnRequest.setAssertionConsumerServiceURL(acsUrl);
if (enableRequestSigning) {
Signature signature = new SignatureBuilder().buildObject();
signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
// Create KeyInfo
KeyInfo keyInfo = new KeyInfoBuilder().buildObject();
X509Data x509Data = new X509DataBuilder().buildObject();
org.opensaml.xmlsec.signature.X509Certificate x509CertElement = new org.opensaml.xmlsec.signature.impl.X509CertificateBuilder().buildObject();
X509Certificate spCertificate = SAMLCertificate.getInstance(appIdentifier, main).getCertificate();
String certString = java.util.Base64.getEncoder().encodeToString(spCertificate.getEncoded());
x509CertElement.setValue(certString);
x509Data.getX509Certificates().add(x509CertElement);
keyInfo.getX509Datas().add(x509Data);
signature.setKeyInfo(keyInfo);
authnRequest.setSignature(signature);
}
return authnRequest;
}
private static String deflateAndBase64RedirectMessage(XMLObject xmlObject) {
try {
String xml = toXmlString(xmlObject);
byte[] xmlBytes = xml.getBytes(StandardCharsets.UTF_8);
// DEFLATE compression as per SAML Redirect binding spec
ByteArrayOutputStream baos = new ByteArrayOutputStream();
DeflaterOutputStream dos = new DeflaterOutputStream(baos, new Deflater(Deflater.DEFLATED, true));
dos.write(xmlBytes);
dos.close();
byte[] deflated = baos.toByteArray();
String base64 = java.util.Base64.getEncoder().encodeToString(deflated);
return URLEncoder.encode(base64, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new RuntimeException("Failed to deflate SAML message", e);
}
}
private static String toXmlString(XMLObject xmlObject) {
try {
Element el = XMLObjectSupport.marshall(xmlObject);
return SerializeSupport.nodeToString(el);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize XML", e);
}
}
private static Response parseSamlResponse(String samlResponseBase64)
throws IOException, XMLParserException, UnmarshallingException {
byte[] decoded = java.util.Base64.getDecoder().decode(samlResponseBase64);
String xml = new String(decoded, StandardCharsets.UTF_8);
try (InputStream inputStream = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))) {
return (Response) XMLObjectSupport.unmarshallFromInputStream(
XMLObjectProviderRegistrySupport.getParserPool(), inputStream);
}
}
private static void verifySamlResponseSignature(Response samlResponse, X509Certificate idpCertificate)
throws SignatureException {
Signature responseSignature = samlResponse.getSignature();
if (responseSignature != null) {
Credential credential = CredentialSupport.getSimpleCredential(idpCertificate, null);
SignatureValidator.validate(responseSignature, credential);
return;
}
boolean foundSignedAssertion = false;
for (Assertion assertion : samlResponse.getAssertions()) {
Signature assertionSignature = assertion.getSignature();
if (assertionSignature != null) {
Credential credential = CredentialSupport.getSimpleCredential(idpCertificate, null);
SignatureValidator.validate(assertionSignature, credential);
foundSignedAssertion = true;
}
}
if (!foundSignedAssertion) {
throw new RuntimeException("Neither SAML Response nor any Assertion is signed");
}
}
private static void validateSamlResponseTimestamps(Response samlResponse) throws SAMLResponseVerificationFailedException {
Instant now = Instant.now();
// Validate response issue instant (should be recent)
if (samlResponse.getIssueInstant() != null) {
Instant responseTime = samlResponse.getIssueInstant();
// Allow 5 minutes clock skew
if (responseTime.isAfter(now.plusSeconds(300)) || responseTime.isBefore(now.minusSeconds(300))) {
throw new SAMLResponseVerificationFailedException();
}
}
// Validate assertion timestamps
for (Assertion assertion : samlResponse.getAssertions()) {
// Check NotBefore
if (assertion.getConditions() != null && assertion.getConditions().getNotBefore() != null) {
if (now.isBefore(assertion.getConditions().getNotBefore())) {
throw new SAMLResponseVerificationFailedException();
}
}
// Check NotOnOrAfter
if (assertion.getConditions() != null && assertion.getConditions().getNotOnOrAfter() != null) {
if (now.isAfter(assertion.getConditions().getNotOnOrAfter())) {
throw new SAMLResponseVerificationFailedException();
}
}
}
}
public static String handleCallback(Main main, TenantIdentifier tenantIdentifier, Storage storage, String samlResponse, String relayState)
throws StorageQueryException, XMLParserException, IOException, UnmarshallingException,
CertificateException, InvalidRelayStateException, SAMLResponseVerificationFailedException,
InvalidClientException, IDPInitiatedLoginDisallowedException, TenantOrAppNotFoundException,
FeatureNotEnabledException {
checkForSAMLFeature(tenantIdentifier.toAppIdentifier(), main);
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
CoreConfig config = Config.getConfig(tenantIdentifier, main);
SAMLClient client = null;
Response response = parseSamlResponse(samlResponse);
String state = null;
String redirectURI = null;
if (relayState != null && !relayState.isEmpty()) {
// sp initiated
var relayStateInfo = samlStorage.getRelayStateInfo(tenantIdentifier, relayState);
if (relayStateInfo == null) {
throw new InvalidRelayStateException();
}
String clientId = relayStateInfo.clientId;
client = samlStorage.getSAMLClient(tenantIdentifier, clientId);
state = relayStateInfo.state;
redirectURI = relayStateInfo.redirectURI;
} else {
// idp initiated
String idpEntityId = response.getIssuer().getValue();
client = samlStorage.getSAMLClientByIDPEntityId(tenantIdentifier, idpEntityId);
redirectURI = client.defaultRedirectURI;
if (!client.allowIDPInitiatedLogin) {
throw new IDPInitiatedLoginDisallowedException();
}
}
if (client == null) {
throw new InvalidClientException();
}
// SAML verification
X509Certificate idpSigningCertificate = getCertificateFromString(client.idpSigningCertificate);
try {
verifySamlResponseSignature(response, idpSigningCertificate);
} catch (SignatureException e) {
throw new SAMLResponseVerificationFailedException();
}
validateSamlResponseTimestamps(response);
validateSamlResponseAudience(response, config.getSAMLSPEntityID());
var claims = extractAllClaims(response);
String code = UUID.randomUUID().toString();
samlStorage.saveSAMLClaims(tenantIdentifier, client.clientId, code, claims, config.getSAMLClaimsValidity());
try {
java.net.URI uri = new java.net.URI(redirectURI);
String query = uri.getQuery();
StringBuilder newQuery = new StringBuilder();
if (query != null && !query.isEmpty()) {
newQuery.append(query).append("&");
}
newQuery.append("code=").append(java.net.URLEncoder.encode(code, java.nio.charset.StandardCharsets.UTF_8));
if (state != null) {
newQuery.append("&state=").append(java.net.URLEncoder.encode(state, java.nio.charset.StandardCharsets.UTF_8));
}
java.net.URI newUri = new java.net.URI(
uri.getScheme(),
uri.getAuthority(),
uri.getPath(),
newQuery.toString(),
uri.getFragment()
);
return newUri.toString();
} catch (URISyntaxException e) {
throw new IllegalStateException("should never happen", e);
}
}
private static void validateSamlResponseAudience(Response samlResponse, String expectedAudience)
throws SAMLResponseVerificationFailedException {
boolean audienceMatched = false;
for (Assertion assertion : samlResponse.getAssertions()) {
Conditions conditions = assertion.getConditions();
if (conditions == null) {
continue;
}
java.util.List<AudienceRestriction> restrictions = conditions.getAudienceRestrictions();
if (restrictions == null || restrictions.isEmpty()) {
continue;
}
for (AudienceRestriction ar : restrictions) {
java.util.List<Audience> audiences = ar.getAudiences();
if (audiences == null || audiences.isEmpty()) {
continue;
}
for (Audience aud : audiences) {
if (expectedAudience.equals(aud.getURI())) {
audienceMatched = true;
break;
}
}
if (audienceMatched) {
break;
}
}
if (audienceMatched) {
break;
}
}
if (!audienceMatched) {
throw new SAMLResponseVerificationFailedException();
}
}
private static JsonObject extractAllClaims(Response samlResponse) {
JsonObject claims = new JsonObject();
for (Assertion assertion : samlResponse.getAssertions()) {
// Extract NameID as a claim
Subject subject = assertion.getSubject();
if (subject != null && subject.getNameID() != null) {
String nameId = subject.getNameID().getValue();
String nameIdFormat = subject.getNameID().getFormat();
JsonArray nameIdArr = new JsonArray();
nameIdArr.add(nameId);
claims.add("NameID", nameIdArr);
if (nameIdFormat != null) {
JsonArray nameIdFormatArr = new JsonArray();
nameIdFormatArr.add(nameIdFormat);
claims.add("NameIDFormat", nameIdFormatArr);
}
}
// Extract all attributes from AttributeStatements
for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) {
for (Attribute attribute : attributeStatement.getAttributes()) {
String attributeName = attribute.getName();
JsonArray attributeValues = new JsonArray();
for (XMLObject attributeValue : attribute.getAttributeValues()) {
if (attributeValue instanceof org.opensaml.saml.saml2.core.AttributeValue) {
org.opensaml.saml.saml2.core.AttributeValue attrValue =
(org.opensaml.saml.saml2.core.AttributeValue) attributeValue;
if (attrValue.getDOM() != null) {
String value = attrValue.getDOM().getTextContent();
if (value != null && !value.trim().isEmpty()) {
attributeValues.add(value.trim());
}
} else if (attrValue.getTextContent() != null) {
String value = attrValue.getTextContent();
if (!value.trim().isEmpty()) {
attributeValues.add(value.trim());
}
}
}
}
if (!attributeValues.isEmpty()) {
claims.add(attributeName, attributeValues);
}
}
}
}
return claims;
}
private static X509Certificate getCertificateFromString(String certString) throws CertificateException {
byte[] certBytes = java.util.Base64.getDecoder().decode(certString);
java.security.cert.CertificateFactory certFactory =
java.security.cert.CertificateFactory.getInstance("X.509");
return (X509Certificate) certFactory.generateCertificate(
new ByteArrayInputStream(certBytes));
}
public static JsonObject getUserInfo(Main main, TenantIdentifier tenantIdentifier, Storage storage, String accessToken, String clientId, boolean isLegacy)
throws TenantOrAppNotFoundException, StorageQueryException,
StorageTransactionLogicException, InvalidCodeException, FeatureNotEnabledException {
checkForSAMLFeature(tenantIdentifier.toAppIdentifier(), main);
SAMLStorage samlStorage = StorageUtils.getSAMLStorage(storage);
SAMLClaimsInfo claimsInfo = samlStorage.getSAMLClaimsAndRemoveCode(tenantIdentifier, accessToken);
if (claimsInfo == null) {
throw new InvalidCodeException();
}
if (clientId != null) {
if (!clientId.equals(claimsInfo.clientId)) {
throw new InvalidCodeException();
}
}
String sub = null;
String email = null;
JsonObject claims = claimsInfo.claims;
if (claims.has("NameID")) {
sub = claims.getAsJsonArray("NameID").get(0).getAsString();
} else if (claims.has("http://schemas.microsoft.com/identity/claims/objectidentifier")) {
sub = claims.getAsJsonArray("http://schemas.microsoft.com/identity/claims/objectidentifier")
.get(0).getAsString();
} else if (claims.has("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")) {
sub = claims.getAsJsonArray("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
.get(0).getAsString();
}
if (claims.has("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")) {
email = claims.getAsJsonArray("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")
.get(0).getAsString();
} else if (claims.has("NameID")) {
String nameIdValue = claims.getAsJsonArray("NameID").get(0).getAsString();
if (nameIdValue.contains("@")) {
email = nameIdValue;
}
}
JsonObject payload = new JsonObject();
payload.add("claims", claims);
payload.addProperty(isLegacy ? "id" : "sub", sub);
payload.addProperty("email", email);
payload.addProperty("aud", claimsInfo.clientId);
return payload;
}
public static String getLegacyACSURL(Main main, AppIdentifier appIdentifier) throws TenantOrAppNotFoundException {
CoreConfig config = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main);
return config.getSAMLLegacyACSURL();
}
public static String getMetadataXML(Main main, TenantIdentifier tenantIdentifier)
throws TenantOrAppNotFoundException, StorageQueryException, FeatureNotEnabledException {
checkForSAMLFeature(tenantIdentifier.toAppIdentifier(), main);
SAMLCertificate certificate = SAMLCertificate.getInstance(tenantIdentifier.toAppIdentifier(), main);
CoreConfig config = Config.getConfig(tenantIdentifier, main);
String spEntityId = config.getSAMLSPEntityID();
try {
X509Certificate cert = certificate.getCertificate();
String certString = java.util.Base64.getEncoder().encodeToString(cert.getEncoded());
String validUntil = java.time.format.DateTimeFormatter.ISO_INSTANT.format(cert.getNotAfter().toInstant());
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>");
sb.append("<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" entityID=\"")
.append(escapeXml(spEntityId)).append("\" validUntil=\"")
.append(escapeXml(validUntil)).append("\">");
sb.append("<md:SPSSODescriptor protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">");
sb.append("<md:KeyDescriptor use=\"signing\">");
sb.append("<ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">");
sb.append("<ds:X509Data>");
sb.append("<ds:X509Certificate>").append(certString).append("</ds:X509Certificate>");
sb.append("</ds:X509Data>");
sb.append("</ds:KeyInfo>");
sb.append("</md:KeyDescriptor>");
sb.append("<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>");
sb.append("</md:SPSSODescriptor>");
sb.append("</md:EntityDescriptor>");
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("Failed to generate SP metadata", e);
}
}
private static String escapeXml(String input) {
if (input == null) {
return "";
}
String result = input;
result = result.replace("&", "&amp;");
result = result.replace("\"", "&quot;");
result = result.replace("<", "&lt;");
result = result.replace(">", "&gt;");
return result;
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.saml;
import java.util.HashMap;
import java.util.Map;
import org.opensaml.core.config.InitializationException;
import org.opensaml.core.config.InitializationService;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
public class SAMLBootstrap {
private static volatile boolean initialized = false;
private SAMLBootstrap() {}
public static void initialize() {
if (initialized) {
return;
}
synchronized (SAMLBootstrap.class) {
if (initialized) {
return;
}
try {
InitializationService.initialize();
initialized = true;
} catch (InitializationException e) {
throw new RuntimeException("Failed to initialize OpenSAML", e);
}
}
}
}

View File

@ -0,0 +1,315 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.saml;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.CertIOException;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import io.supertokens.Main;
import io.supertokens.ResourceDistributor;
import io.supertokens.output.Logging;
import io.supertokens.pluginInterface.KeyValueInfo;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.sqlStorage.SQLStorage;
import io.supertokens.storageLayer.StorageLayer;
public class SAMLCertificate extends ResourceDistributor.SingletonResource {
private static final String RESOURCE_KEY = "io.supertokens.saml.SAMLCertificate";
private final Main main;
private final AppIdentifier appIdentifier;
private static final String SAML_KEY_PAIR_NAME = "saml_key_pair";
private static final String SAML_CERTIFICATE_NAME = "saml_certificate";
private KeyPair spKeyPair = null;
private X509Certificate spCertificate = null;
private SAMLCertificate(AppIdentifier appIdentifier, Main main) throws
TenantOrAppNotFoundException {
this.main = main;
this.appIdentifier = appIdentifier;
try {
if (!Main.isTesting) {
// Creation of new certificate is slow, not really necessary to create one for each test
this.getCertificate();
}
} catch (StorageQueryException | TenantOrAppNotFoundException e) {
Logging.error(main, appIdentifier.getAsPublicTenantIdentifier(), "Error while fetching SAML key and certificate",
false, e);
}
}
public synchronized X509Certificate getCertificate()
throws StorageQueryException, TenantOrAppNotFoundException {
if (this.spCertificate == null || this.spCertificate.getNotAfter().before(new Date())) {
maybeGenerateNewCertificateAndUpdateInDb();
}
return this.spCertificate;
}
private void maybeGenerateNewCertificateAndUpdateInDb() throws TenantOrAppNotFoundException {
SQLStorage storage = (SQLStorage) StorageLayer.getStorage(
this.appIdentifier.getAsPublicTenantIdentifier(), main);
try {
storage.startTransaction(con -> {
KeyValueInfo keyPairInfo = storage.getKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_KEY_PAIR_NAME);
KeyValueInfo certInfo = storage.getKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_CERTIFICATE_NAME);
if (keyPairInfo == null || certInfo == null) {
try {
generateNewCertificate();
} catch (Exception e) {
throw new RuntimeException(e);
}
try {
String keyPairStr = serializeKeyPair(spKeyPair);
String certStr = serializeCertificate(spCertificate);
keyPairInfo = new KeyValueInfo(keyPairStr);
certInfo = new KeyValueInfo(certStr);
} catch (IOException e) {
throw new RuntimeException("Failed to serialize key pair or certificate", e);
}
storage.setKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_KEY_PAIR_NAME, keyPairInfo);
storage.setKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_CERTIFICATE_NAME, certInfo);
}
String keyPairStr = keyPairInfo.value;
String certStr = certInfo.value;
try {
this.spKeyPair = deserializeKeyPair(keyPairStr);
this.spCertificate = deserializeCertificate(certStr);
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize key pair or certificate", e);
}
// If the certificate has expired, generate and persist a new one
if (this.spCertificate.getNotAfter().before(new Date())) {
try {
generateNewCertificate();
String newKeyPairStr = serializeKeyPair(spKeyPair);
String newCertStr = serializeCertificate(spCertificate);
KeyValueInfo newKeyPairInfo = new KeyValueInfo(newKeyPairStr);
KeyValueInfo newCertInfo = new KeyValueInfo(newCertStr);
storage.setKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_KEY_PAIR_NAME, newKeyPairInfo);
storage.setKeyValue_Transaction(this.appIdentifier.getAsPublicTenantIdentifier(), con, SAML_CERTIFICATE_NAME, newCertInfo);
} catch (Exception e) {
throw new RuntimeException("Failed to regenerate expired certificate", e);
}
}
return null;
});
} catch (StorageTransactionLogicException | StorageQueryException e) {
throw new RuntimeException("Storage error", e);
}
}
void generateNewCertificate()
throws NoSuchAlgorithmException, CertificateException, OperatorCreationException, CertIOException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(4096);
spKeyPair = keyGen.generateKeyPair();
spCertificate = generateSelfSignedCertificate();
}
private X509Certificate generateSelfSignedCertificate()
throws CertIOException, OperatorCreationException, CertificateException {
// Create a production-ready self-signed X.509 certificate using BouncyCastle
Date notBefore = new Date();
Date notAfter = new Date(notBefore.getTime() + 10 * 365L * 24 * 60 * 60 * 1000); // 10 year validity
// Create the certificate subject and issuer (same for self-signed)
X500Name subject = new X500Name("CN=SAML-SP, O=SuperTokens, C=US");
X500Name issuer = subject; // Self-signed
// Generate a random serial number (128 bits for good uniqueness)
SecureRandom random = new SecureRandom();
java.math.BigInteger serialNumber = new java.math.BigInteger(128, random);
// Create the certificate builder
JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
issuer,
serialNumber,
notBefore,
notAfter,
subject,
spKeyPair.getPublic()
);
// Add extensions for proper SAML usage
// Key Usage: digitalSignature and keyEncipherment
KeyUsage keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment);
certBuilder.addExtension(Extension.keyUsage, true, keyUsage);
// Basic Constraints: not a CA
BasicConstraints basicConstraints = new BasicConstraints(false);
certBuilder.addExtension(Extension.basicConstraints, true, basicConstraints);
// Create the content signer
ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withRSA")
.build(spKeyPair.getPrivate());
// Build the certificate
X509CertificateHolder certHolder = certBuilder.build(contentSigner);
// Convert to standard X509Certificate
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
return converter.getCertificate(certHolder);
}
/**
* Serializes a KeyPair to a Base64 encoded string format
*/
private String serializeKeyPair(KeyPair keyPair) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
// Write private key
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
baos.write(Base64.getEncoder().encode(privateKeyBytes));
baos.write('\n');
// Write public key
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
baos.write(Base64.getEncoder().encode(publicKeyBytes));
return baos.toString();
}
/**
* Deserializes a KeyPair from a Base64 encoded string format
*/
private KeyPair deserializeKeyPair(String keyPairStr) throws Exception {
String[] parts = keyPairStr.split("\n");
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid key pair string format");
}
// Decode private key
byte[] privateKeyBytes = Base64.getDecoder().decode(parts[0]);
PKCS8EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
// Decode public key
byte[] publicKeyBytes = Base64.getDecoder().decode(parts[1]);
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBytes);
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
return new KeyPair(publicKey, privateKey);
}
/**
* Serializes an X509Certificate to a Base64 encoded string format
*/
private String serializeCertificate(X509Certificate certificate) throws IOException {
try {
byte[] certBytes = certificate.getEncoded();
return Base64.getEncoder().encodeToString(certBytes);
} catch (CertificateException e) {
throw new IOException("Failed to encode certificate", e);
}
}
/**
* Deserializes an X509Certificate from a Base64 encoded string format
*/
private X509Certificate deserializeCertificate(String certStr) throws Exception {
try {
byte[] certBytes = Base64.getDecoder().decode(certStr);
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
ByteArrayInputStream bais = new ByteArrayInputStream(certBytes);
return (X509Certificate) certFactory.generateCertificate(bais);
} catch (CertificateException e) {
throw new Exception("Failed to decode certificate", e);
}
}
public static SAMLCertificate getInstance(AppIdentifier appIdentifier, Main main)
throws TenantOrAppNotFoundException {
return (SAMLCertificate) main.getResourceDistributor()
.getResource(appIdentifier, RESOURCE_KEY);
}
public static void loadForAllTenants(Main main, List<AppIdentifier> apps,
List<TenantIdentifier> tenantsThatChanged) {
try {
main.getResourceDistributor().withResourceDistributorLock(() -> {
Map<ResourceDistributor.KeyClass, ResourceDistributor.SingletonResource> existingResources =
main.getResourceDistributor()
.getAllResourcesWithResourceKey(RESOURCE_KEY);
main.getResourceDistributor().clearAllResourcesWithResourceKey(RESOURCE_KEY);
for (AppIdentifier app : apps) {
ResourceDistributor.SingletonResource resource = existingResources.get(
new ResourceDistributor.KeyClass(app, RESOURCE_KEY));
if (resource != null && !tenantsThatChanged.contains(app.getAsPublicTenantIdentifier())) {
main.getResourceDistributor().setResource(app, RESOURCE_KEY,
resource);
} else {
try {
main.getResourceDistributor()
.setResource(app, RESOURCE_KEY,
new SAMLCertificate(app, main));
} catch (TenantOrAppNotFoundException e) {
Logging.error(main, app.getAsPublicTenantIdentifier(), e.getMessage(), false);
// continue loading other resources
}
}
}
return null;
});
} catch (ResourceDistributor.FuncException e) {
throw new IllegalStateException("should never happen", e);
}
}
}

View File

@ -0,0 +1,4 @@
package io.supertokens.saml.exceptions;
public class IDPInitiatedLoginDisallowedException extends Exception {
}

View File

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.saml.exceptions;
public class InvalidClientException extends Exception {
}

View File

@ -0,0 +1,5 @@
package io.supertokens.saml.exceptions;
public class InvalidCodeException extends Exception {
}

View File

@ -0,0 +1,5 @@
package io.supertokens.saml.exceptions;
public class InvalidRelayStateException extends Exception {
}

View File

@ -0,0 +1,20 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.saml.exceptions;
public class MalformedSAMLMetadataXMLException extends Exception {
}

View File

@ -0,0 +1,5 @@
package io.supertokens.saml.exceptions;
public class SAMLResponseVerificationFailedException extends Exception {
}

View File

@ -36,7 +36,9 @@ import io.supertokens.pluginInterface.multitenancy.MultitenancyStorage;
import io.supertokens.pluginInterface.multitenancy.TenantConfig;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan;
import io.supertokens.pluginInterface.useridmapping.UserIdMapping;
import io.supertokens.telemetry.TelemetryProvider;
import io.supertokens.useridmapping.UserIdType;
import jakarta.servlet.ServletException;
import org.jetbrains.annotations.TestOnly;
@ -48,11 +50,13 @@ import java.net.URL;
import java.net.URLClassLoader;
import java.util.*;
@WithinOtelSpan
public class StorageLayer extends ResourceDistributor.SingletonResource {
public static final String RESOURCE_KEY = "io.supertokens.storageLayer.StorageLayer";
private final Storage storage;
private static URLClassLoader ucl = null;
private static Storage storageInstanceForEnv = null;
public Storage getUnderlyingStorage() {
return storage;
@ -66,6 +70,37 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
return getNewInstance(main, config, tenantIdentifier, doNotLog, true);
}
public static void updateConfigJsonFromEnv(Main main, JsonObject configJson) {
if (storageInstanceForEnv == null) {
Storage result;
if (StorageLayer.ucl == null) {
result = new Start(main);
} else {
Storage storageLayer = null;
ServiceLoader<Storage> sl = ServiceLoader.load(Storage.class, ucl);
for (Storage plugin : sl) {
if (storageLayer == null) {
storageLayer = plugin;
} else {
throw new QuitProgramException(
"Multiple database plugins found. Please make sure that just one plugin is in the "
+ "/plugin" + " "
+ "folder of the installation. Alternatively, please redownload and install "
+ "SuperTokens" + ".");
}
}
if (storageLayer != null) {
result = storageLayer;
} else {
result = new Start(main);
}
}
storageInstanceForEnv = result;
}
storageInstanceForEnv.updateConfigJsonFromEnv(configJson);
}
private static Storage getNewInstance(Main main, JsonObject config, TenantIdentifier tenantIdentifier, boolean doNotLog, boolean isBulkImportProxy) throws InvalidConfigException {
Storage result;
if (StorageLayer.ucl == null) {
@ -85,7 +120,7 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
}
}
if (storageLayer != null && !main.isForceInMemoryDB()
&& (storageLayer.canBeUsed(config) || CLIOptions.get(main).isForceNoInMemoryDB())) {
&& (storageLayer. canBeUsed(config) || CLIOptions.get(main).isForceNoInMemoryDB())) {
if (isBulkImportProxy) {
result = storageLayer.createBulkImportProxyStorageInstance();
} else {
@ -115,9 +150,7 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
this.storage = storage;
}
private StorageLayer(Main main, String pluginFolderPath, JsonObject configJson, TenantIdentifier tenantIdentifier)
throws MalformedURLException, InvalidConfigException {
Logging.info(main, tenantIdentifier, "Loading storage layer.", true);
public static void loadStorageUCL(String pluginFolderPath) throws MalformedURLException {
File loc = new File(pluginFolderPath);
File[] flist = loc.listFiles(file -> file.getPath().toLowerCase().endsWith(".jar"));
@ -136,6 +169,12 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
}
}
}
private StorageLayer(Main main, JsonObject configJson, TenantIdentifier tenantIdentifier)
throws InvalidConfigException {
Logging.info(main, tenantIdentifier, "Loading storage layer.", true);
this.storage = getNewStorageInstance(main, configJson, tenantIdentifier, false);
if (this.storage instanceof Start) {
@ -203,10 +242,10 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
return (StorageLayer) main.getResourceDistributor().getResource(tenantIdentifier, RESOURCE_KEY);
}
public static void initPrimary(Main main, String pluginFolderPath, JsonObject configJson)
public static void initPrimary(Main main, JsonObject configJson)
throws MalformedURLException, InvalidConfigException {
main.getResourceDistributor().setResource(new TenantIdentifier(null, null, null), RESOURCE_KEY,
new StorageLayer(main, pluginFolderPath, configJson, TenantIdentifier.BASE_TENANT));
new StorageLayer(main, configJson, TenantIdentifier.BASE_TENANT));
}
public static void loadAllTenantStorage(Main main, TenantConfig[] tenants)
@ -321,7 +360,8 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
new ArrayList<>(storageToTenantIdentifiersMap.get(((StorageLayer) resource).storage)));
((StorageLayer) resource).storage.initFileLogging(
Config.getBaseConfig(main).getInfoLogPath(main),
Config.getBaseConfig(main).getErrorLogPath(main));
Config.getBaseConfig(main).getErrorLogPath(main),
TelemetryProvider.getInstance(main));
} catch (DbInitException e) {
Logging.error(main, TenantIdentifier.BASE_TENANT, e.getMessage(), false, e);
@ -348,6 +388,7 @@ public class StorageLayer extends ResourceDistributor.SingletonResource {
}
}
@WithinOtelSpan
public static Storage getStorage(TenantIdentifier tenantIdentifier, Main main)
throws TenantOrAppNotFoundException {
return getInstance(tenantIdentifier, main).storage;

View File

@ -0,0 +1,76 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.telemetry;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.context.Scope;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
@Aspect
public class MethodSpanner {
@Around("execution(* (@io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan *).*(..))")
public Object anyMethodInClassAnnotatedWithWithinOtelSpan(ProceedingJoinPoint joinPoint) throws Throwable {
return withinOtelSpan(joinPoint);
}
@Around("execution(@io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan * *(..))")
public Object withinOtelSpan(ProceedingJoinPoint joinPoint) throws Throwable {
Span span = GlobalOpenTelemetry.get().getTracer("core-tracer")
.spanBuilder(joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName())
.startSpan();
try (Scope spanScope = span.makeCurrent()) {
Map<String, String> methodArguments = new HashMap<>();
for (Object argument : joinPoint.getArgs()) {
if (argument != null) {
methodArguments.put(argument.getClass().getCanonicalName(), String.valueOf(argument));
} else {
methodArguments.put("null", "null");
}
}
span.setAttribute("method.arguments", methodArguments.keySet().stream().map(key -> key + ": " + methodArguments.get(key))
.collect(Collectors.joining(", ", "{", "}")));
try {
Object result = joinPoint.proceed(); //run the actual method
if (result != null) {
span.setAttribute("method.returns",
result.getClass().getCanonicalName() + " -> " + result);
} else {
span.setAttribute("method.returns", "void/null");
}
span.setStatus(StatusCode.OK);
return result;
} catch (Throwable e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
}
} finally {
span.end();
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.telemetry;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.common.AttributesBuilder;
import io.opentelemetry.api.trace.Span;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class TelemetryAppender {
private final static TelemetryAppender instance = new TelemetryAppender();
public static TelemetryAppender getInstance() {
return instance;
}
private TelemetryAppender() {
}
public void appendEventToCurrentSpan(String eventName, Map<String, String> eventData, long timestamp) {
Span.current().addEvent(eventName, fromMap(eventData), timestamp, TimeUnit.MILLISECONDS);
}
public void appendAttributesToCurrentSpan(Map<String, String> attributes) {
Span.current().setAllAttributes(fromMap(attributes));
}
private Attributes fromMap(Map<String, String> map) {
AttributesBuilder ab = Attributes.builder();
if(map != null) {
for (Map.Entry<String, String> e : map.entrySet()) {
ab.put(e.getKey(), e.getValue());
}
}
return ab.build();
}
}

View File

@ -19,7 +19,7 @@ package io.supertokens.telemetry;
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanBuilder;
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.propagation.ContextPropagators;
@ -36,19 +36,19 @@ import io.supertokens.ResourceDistributor;
import io.supertokens.config.Config;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.opentelemetry.OtelProvider;
import org.jetbrains.annotations.TestOnly;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static io.opentelemetry.semconv.ServiceAttributes.SERVICE_NAME;
public class TelemetryProvider extends ResourceDistributor.SingletonResource {
private static final String RESOURCE_ID = "io.supertokens.telemetry.TelemetryProvider";
public class TelemetryProvider extends ResourceDistributor.SingletonResource implements OtelProvider {
private final OpenTelemetry openTelemetry;
private static synchronized TelemetryProvider getInstance(Main main) {
public static TelemetryProvider getInstance(Main main) {
TelemetryProvider instance = null;
try {
instance = (TelemetryProvider) main.getResourceDistributor()
@ -63,15 +63,22 @@ public class TelemetryProvider extends ResourceDistributor.SingletonResource {
.setResource(TenantIdentifier.BASE_TENANT, RESOURCE_ID, new TelemetryProvider(main));
}
public static void createLogEvent(Main main, TenantIdentifier tenantIdentifier, String logMessage,
@Override
public void createLogEvent(TenantIdentifier tenantIdentifier, String logMessage,
String logLevel) {
getInstance(main).openTelemetry.getTracer("core-tracer")
.spanBuilder(logLevel)
.setParent(Context.current())
.setAttribute("tenant.connectionUriDomain", tenantIdentifier.getConnectionUriDomain())
.setAttribute("tenant.appId", tenantIdentifier.getAppId())
.setAttribute("tenant.tenantId", tenantIdentifier.getTenantId())
.startSpan()
createLogEvent(tenantIdentifier, logMessage, logLevel, Map.of());
}
@Override
public void createLogEvent(TenantIdentifier tenantIdentifier, String logMessage,
String logLevel, Map<String, String> additionalAttributes) {
if (openTelemetry == null) {
return; // no telemetry provider available
}
SpanBuilder spanBuilder = createSpanBuilder(tenantIdentifier, logLevel, additionalAttributes);
spanBuilder.startSpan()
.addEvent("log",
Attributes.builder()
.put("message", logMessage)
@ -80,45 +87,52 @@ public class TelemetryProvider extends ResourceDistributor.SingletonResource {
.end();
}
public static Span startSpan(Main main, TenantIdentifier tenantIdentifier, String spanName) {
Span span = getInstance(main).openTelemetry.getTracer("core-tracer")
private SpanBuilder createSpanBuilder(TenantIdentifier tenantIdentifier, String spanName,
Map<String, String> additionalAttributes) {
SpanBuilder spanBuilder = openTelemetry.getTracer("core-tracer")
.spanBuilder(spanName)
.setParent(Context.current())
.setParent(Context.current());
return addAttributesToSpanBuilder(spanBuilder, tenantIdentifier, additionalAttributes);
}
private SpanBuilder addAttributesToSpanBuilder(SpanBuilder spanBuilder, TenantIdentifier tenantIdentifier,
Map<String, String> additionalAttributes) {
spanBuilder
.setAttribute("tenant.connectionUriDomain", tenantIdentifier.getConnectionUriDomain())
.setAttribute("tenant.appId", tenantIdentifier.getAppId())
.setAttribute("tenant.tenantId", tenantIdentifier.getTenantId())
.startSpan();
.setAttribute("tenant.tenantId", tenantIdentifier.getTenantId());
span.makeCurrent(); // Set the span as the current context
return span;
}
public static Span endSpan(Span span) {
if (span != null) {
span.end();
if (additionalAttributes != null && !additionalAttributes.isEmpty()) {
// Add additional attributes to the span
for (Map.Entry<String, String> attribute : additionalAttributes.entrySet()) {
spanBuilder.setAttribute(attribute.getKey(), attribute.getValue());
}
}
return span;
}
public static Span addEventToSpan(Span span, String eventName, Attributes attributes) {
if (span != null) {
span.addEvent(eventName, attributes, System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
return span;
return spanBuilder;
}
private static OpenTelemetry initializeOpenTelemetry(Main main) {
String collectorUri = Config.getBaseConfig(main).getOtelCollectorConnectionURI();
if (GlobalOpenTelemetry.get() != null) {
return GlobalOpenTelemetry.get(); // already initialized
}
if (collectorUri == null || collectorUri.isEmpty()) {
return null;
}
if (getInstance(main) != null && getInstance(main).openTelemetry != null) {
return getInstance(main).openTelemetry; // already initialized
}
Resource resource = Resource.getDefault().toBuilder()
.put(SERVICE_NAME, "supertokens-core")
.build();
String collectorUri = Config.getBaseConfig(main).getOtelCollectorConnectionURI();
SdkTracerProvider sdkTracerProvider =
SdkTracerProvider.builder()
.setResource(resource)

View File

@ -23,11 +23,11 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.supertokens.pluginInterface.exceptions.InvalidConfigException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.util.Map;
public class ConfigMapper {
public static <T> T mapConfig(JsonObject config, Class<T> clazz) throws InvalidConfigException {
try {
T result = clazz.newInstance();

View File

@ -39,6 +39,7 @@ public class SemVer implements Comparable<SemVer> {
public static final SemVer v5_1 = new SemVer("5.1");
public static final SemVer v5_2 = new SemVer("5.2");
public static final SemVer v5_3 = new SemVer("5.3");
public static final SemVer v5_4 = new SemVer("5.4");
final private String version;

View File

@ -38,7 +38,7 @@ public class VersionFile {
if (core_version == null || plugin_interface_version == null) {
throw new QuitProgramException(
"version.yaml file seems to be corrupted. Please redownload and install SuperTokens from "
+ "https://supertokens.io/dashboard");
+ "https://supertokens.com/");
}
}

View File

@ -16,6 +16,19 @@
package io.supertokens.webserver;
import java.io.File;
import java.util.UUID;
import java.util.logging.Handler;
import java.util.logging.Logger;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Tomcat;
import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.jetbrains.annotations.TestOnly;
import io.supertokens.Main;
import io.supertokens.OperatingSystem;
import io.supertokens.ResourceDistributor;
@ -25,50 +38,150 @@ import io.supertokens.exceptions.QuitProgramException;
import io.supertokens.output.Logging;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.webserver.api.accountlinking.*;
import io.supertokens.webserver.api.accountlinking.CanCreatePrimaryUserAPI;
import io.supertokens.webserver.api.accountlinking.CanLinkAccountsAPI;
import io.supertokens.webserver.api.accountlinking.CreatePrimaryUserAPI;
import io.supertokens.webserver.api.accountlinking.LinkAccountsAPI;
import io.supertokens.webserver.api.accountlinking.UnlinkAccountAPI;
import io.supertokens.webserver.api.bulkimport.BulkImportAPI;
import io.supertokens.webserver.api.bulkimport.CountBulkImportUsersAPI;
import io.supertokens.webserver.api.bulkimport.DeleteBulkImportUserAPI;
import io.supertokens.webserver.api.bulkimport.ImportUserAPI;
import io.supertokens.webserver.api.core.*;
import io.supertokens.webserver.api.dashboard.*;
import io.supertokens.webserver.api.core.ActiveUsersCountAPI;
import io.supertokens.webserver.api.core.ApiVersionAPI;
import io.supertokens.webserver.api.core.ConfigAPI;
import io.supertokens.webserver.api.core.DeleteUserAPI;
import io.supertokens.webserver.api.core.EEFeatureFlagAPI;
import io.supertokens.webserver.api.core.GetUserByIdAPI;
import io.supertokens.webserver.api.core.HelloAPI;
import io.supertokens.webserver.api.core.JWKSPublicAPI;
import io.supertokens.webserver.api.core.LicenseKeyAPI;
import io.supertokens.webserver.api.core.ListUsersByAccountInfoAPI;
import io.supertokens.webserver.api.core.NotFoundOrHelloAPI;
import io.supertokens.webserver.api.core.RequestStatsAPI;
import io.supertokens.webserver.api.core.SearchTagsAPI;
import io.supertokens.webserver.api.core.TelemetryAPI;
import io.supertokens.webserver.api.core.UsersAPI;
import io.supertokens.webserver.api.core.UsersCountAPI;
import io.supertokens.webserver.api.dashboard.DashboardSignInAPI;
import io.supertokens.webserver.api.dashboard.DashboardUserAPI;
import io.supertokens.webserver.api.dashboard.GetDashboardSessionsForUserAPI;
import io.supertokens.webserver.api.dashboard.GetDashboardUsersAPI;
import io.supertokens.webserver.api.dashboard.GetTenantCoreConfigForDashboardAPI;
import io.supertokens.webserver.api.dashboard.RevokeSessionAPI;
import io.supertokens.webserver.api.dashboard.VerifyDashboardUserSessionAPI;
import io.supertokens.webserver.api.emailpassword.ConsumeResetPasswordAPI;
import io.supertokens.webserver.api.emailpassword.GeneratePasswordResetTokenAPI;
import io.supertokens.webserver.api.emailpassword.ImportUserWithPasswordHashAPI;
import io.supertokens.webserver.api.emailpassword.ResetPasswordAPI;
import io.supertokens.webserver.api.emailpassword.SignInAPI;
import io.supertokens.webserver.api.emailpassword.SignUpAPI;
import io.supertokens.webserver.api.emailpassword.UserAPI;
import io.supertokens.webserver.api.emailpassword.*;
import io.supertokens.webserver.api.emailverification.GenerateEmailVerificationTokenAPI;
import io.supertokens.webserver.api.emailverification.RevokeAllTokensForUserAPI;
import io.supertokens.webserver.api.emailverification.UnverifyEmailAPI;
import io.supertokens.webserver.api.emailverification.VerifyEmailAPI;
import io.supertokens.webserver.api.jwt.JWKSAPI;
import io.supertokens.webserver.api.jwt.JWTSigningAPI;
import io.supertokens.webserver.api.multitenancy.*;
import io.supertokens.webserver.api.multitenancy.AssociateUserToTenantAPI;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateAppAPI;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateAppV2API;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateConnectionUriDomainAPI;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateConnectionUriDomainV2API;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateTenantOrGetTenantAPI;
import io.supertokens.webserver.api.multitenancy.CreateOrUpdateTenantOrGetTenantV2API;
import io.supertokens.webserver.api.multitenancy.DisassociateUserFromTenant;
import io.supertokens.webserver.api.multitenancy.ListAppsAPI;
import io.supertokens.webserver.api.multitenancy.ListAppsV2API;
import io.supertokens.webserver.api.multitenancy.ListConnectionUriDomainsAPI;
import io.supertokens.webserver.api.multitenancy.ListConnectionUriDomainsV2API;
import io.supertokens.webserver.api.multitenancy.ListTenantsAPI;
import io.supertokens.webserver.api.multitenancy.ListTenantsV2API;
import io.supertokens.webserver.api.multitenancy.RemoveAppAPI;
import io.supertokens.webserver.api.multitenancy.RemoveConnectionUriDomainAPI;
import io.supertokens.webserver.api.multitenancy.RemoveTenantAPI;
import io.supertokens.webserver.api.multitenancy.thirdparty.CreateOrUpdateThirdPartyConfigAPI;
import io.supertokens.webserver.api.multitenancy.thirdparty.RemoveThirdPartyConfigAPI;
import io.supertokens.webserver.api.oauth.*;
import io.supertokens.webserver.api.passwordless.*;
import io.supertokens.webserver.api.session.*;
import io.supertokens.webserver.api.oauth.CreateUpdateOrGetOAuthClientAPI;
import io.supertokens.webserver.api.oauth.OAuthAcceptAuthConsentRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthAcceptAuthLoginRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthAcceptAuthLogoutRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthAuthAPI;
import io.supertokens.webserver.api.oauth.OAuthClientListAPI;
import io.supertokens.webserver.api.oauth.OAuthGetAuthConsentRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthGetAuthLoginRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthLogoutAPI;
import io.supertokens.webserver.api.oauth.OAuthRejectAuthConsentRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthRejectAuthLoginRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthRejectAuthLogoutRequestAPI;
import io.supertokens.webserver.api.oauth.OAuthTokenAPI;
import io.supertokens.webserver.api.oauth.OAuthTokenIntrospectAPI;
import io.supertokens.webserver.api.oauth.RemoveOAuthClientAPI;
import io.supertokens.webserver.api.oauth.RevokeOAuthSessionAPI;
import io.supertokens.webserver.api.oauth.RevokeOAuthTokenAPI;
import io.supertokens.webserver.api.oauth.RevokeOAuthTokensAPI;
import io.supertokens.webserver.api.passwordless.CheckCodeAPI;
import io.supertokens.webserver.api.passwordless.ConsumeCodeAPI;
import io.supertokens.webserver.api.passwordless.CreateCodeAPI;
import io.supertokens.webserver.api.passwordless.DeleteCodeAPI;
import io.supertokens.webserver.api.passwordless.DeleteCodesAPI;
import io.supertokens.webserver.api.passwordless.GetCodesAPI;
import io.supertokens.webserver.api.saml.CreateOrUpdateSamlClientAPI;
import io.supertokens.webserver.api.saml.CreateSamlLoginRedirectAPI;
import io.supertokens.webserver.api.saml.GetUserInfoAPI;
import io.supertokens.webserver.api.saml.HandleSamlCallbackAPI;
import io.supertokens.webserver.api.saml.LegacyAuthorizeAPI;
import io.supertokens.webserver.api.saml.LegacyCallbackAPI;
import io.supertokens.webserver.api.saml.LegacyTokenAPI;
import io.supertokens.webserver.api.saml.LegacyUserinfoAPI;
import io.supertokens.webserver.api.saml.ListSamlClientsAPI;
import io.supertokens.webserver.api.saml.RemoveSamlClientAPI;
import io.supertokens.webserver.api.saml.SPMetadataAPI;
import io.supertokens.webserver.api.session.HandshakeAPI;
import io.supertokens.webserver.api.session.JWTDataAPI;
import io.supertokens.webserver.api.session.RefreshSessionAPI;
import io.supertokens.webserver.api.session.SessionAPI;
import io.supertokens.webserver.api.session.SessionDataAPI;
import io.supertokens.webserver.api.session.SessionRegenerateAPI;
import io.supertokens.webserver.api.session.SessionRemoveAPI;
import io.supertokens.webserver.api.session.SessionUserAPI;
import io.supertokens.webserver.api.session.VerifySessionAPI;
import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI;
import io.supertokens.webserver.api.thirdparty.SignInUpAPI;
import io.supertokens.webserver.api.totp.*;
import io.supertokens.webserver.api.totp.CreateOrUpdateTotpDeviceAPI;
import io.supertokens.webserver.api.totp.GetTotpDevicesAPI;
import io.supertokens.webserver.api.totp.ImportTotpDeviceAPI;
import io.supertokens.webserver.api.totp.RemoveTotpDeviceAPI;
import io.supertokens.webserver.api.totp.VerifyTotpAPI;
import io.supertokens.webserver.api.totp.VerifyTotpDeviceAPI;
import io.supertokens.webserver.api.useridmapping.RemoveUserIdMappingAPI;
import io.supertokens.webserver.api.useridmapping.UpdateExternalUserIdInfoAPI;
import io.supertokens.webserver.api.useridmapping.UserIdMappingAPI;
import io.supertokens.webserver.api.usermetadata.RemoveUserMetadataAPI;
import io.supertokens.webserver.api.usermetadata.UserMetadataAPI;
import io.supertokens.webserver.api.userroles.*;
import io.supertokens.webserver.api.webauthn.*;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.LifecycleState;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Tomcat;
import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.jetbrains.annotations.TestOnly;
import java.io.File;
import java.util.UUID;
import java.util.logging.Handler;
import java.util.logging.Logger;
import io.supertokens.webserver.api.userroles.AddUserRoleAPI;
import io.supertokens.webserver.api.userroles.CreateRoleAPI;
import io.supertokens.webserver.api.userroles.GetPermissionsForRoleAPI;
import io.supertokens.webserver.api.userroles.GetRolesAPI;
import io.supertokens.webserver.api.userroles.GetRolesForPermissionAPI;
import io.supertokens.webserver.api.userroles.GetRolesForUserAPI;
import io.supertokens.webserver.api.userroles.GetUsersForRoleAPI;
import io.supertokens.webserver.api.userroles.RemovePermissionsForRoleAPI;
import io.supertokens.webserver.api.userroles.RemoveRoleAPI;
import io.supertokens.webserver.api.userroles.RemoveUserRoleAPI;
import io.supertokens.webserver.api.webauthn.ConsumeRecoverAccountTokenAPI;
import io.supertokens.webserver.api.webauthn.CredentialsRegisterAPI;
import io.supertokens.webserver.api.webauthn.GenerateRecoverAccountTokenAPI;
import io.supertokens.webserver.api.webauthn.GetCredentialAPI;
import io.supertokens.webserver.api.webauthn.GetGeneratedOptionsAPI;
import io.supertokens.webserver.api.webauthn.GetUserFromRecoverAccountTokenAPI;
import io.supertokens.webserver.api.webauthn.ListCredentialsAPI;
import io.supertokens.webserver.api.webauthn.OptionsRegisterAPI;
import io.supertokens.webserver.api.webauthn.RemoveCredentialAPI;
import io.supertokens.webserver.api.webauthn.RemoveOptionsAPI;
import io.supertokens.webserver.api.webauthn.SignInOptionsAPI;
import io.supertokens.webserver.api.webauthn.SignUpWithCredentialRegisterAPI;
import io.supertokens.webserver.api.webauthn.UpdateUserEmailAPI;
public class Webserver extends ResourceDistributor.SingletonResource {
@ -312,6 +425,19 @@ public class Webserver extends ResourceDistributor.SingletonResource {
addAPI(new RevokeOAuthSessionAPI(main));
addAPI(new OAuthLogoutAPI(main));
// saml
addAPI(new CreateOrUpdateSamlClientAPI(main));
addAPI(new ListSamlClientsAPI(main));
addAPI(new RemoveSamlClientAPI(main));
addAPI(new CreateSamlLoginRedirectAPI(main));
addAPI(new HandleSamlCallbackAPI(main));
addAPI(new GetUserInfoAPI(main));
addAPI(new LegacyAuthorizeAPI(main));
addAPI(new LegacyCallbackAPI(main));
addAPI(new LegacyTokenAPI(main));
addAPI(new LegacyUserinfoAPI(main));
addAPI(new SPMetadataAPI(main));
//webauthn
addAPI(new OptionsRegisterAPI(main));
addAPI(new SignInOptionsAPI(main));

View File

@ -32,6 +32,7 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.useridmapping.UserIdType;
import io.supertokens.utils.SemVer;
@ -81,10 +82,11 @@ public abstract class WebserverAPI extends HttpServlet {
supportedVersions.add(SemVer.v5_1);
supportedVersions.add(SemVer.v5_2);
supportedVersions.add(SemVer.v5_3);
supportedVersions.add(SemVer.v5_4);
}
public static SemVer getLatestCDIVersion() {
return SemVer.v5_3;
return SemVer.v5_4;
}
public SemVer getLatestCDIVersionForRequest(HttpServletRequest req)
@ -121,6 +123,12 @@ public abstract class WebserverAPI extends HttpServlet {
resp.getWriter().println(message);
}
protected void sendXMLResponse(int statusCode, String message, HttpServletResponse resp) throws IOException {
resp.setStatus(statusCode);
resp.setHeader("Content-Type", "text/xml; charset=UTF-8");
resp.getWriter().println(message);
}
protected void sendJsonResponse(int statusCode, JsonElement json, HttpServletResponse resp) throws IOException {
resp.setStatus(statusCode);
resp.setHeader("Content-Type", "application/json; charset=UTF-8");
@ -358,6 +366,7 @@ public abstract class WebserverAPI extends HttpServlet {
return new AppIdentifier(this.getConnectionUriDomain(req), this.getAppId(req));
}
@WithinOtelSpan
protected AppIdentifier getAppIdentifier(HttpServletRequest req)
throws ServletException, TenantOrAppNotFoundException {
AppIdentifier appIdentifier = getAppIdentifierWithoutVerifying(req);

View File

@ -21,6 +21,7 @@ import io.supertokens.pluginInterface.Storage;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.opentelemetry.WithinOtelSpan;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.utils.RateLimiter;
import io.supertokens.webserver.WebserverAPI;
@ -32,6 +33,7 @@ import java.io.IOException;
// the point of this API is only to test that the server is up and running.
@WithinOtelSpan
public class HelloAPI extends WebserverAPI {
private static final long serialVersionUID = 1L;
@ -75,6 +77,7 @@ public class HelloAPI extends WebserverAPI {
handleRequest(req, resp);
}
private void handleRequest(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
// API is app specific
@ -84,6 +87,13 @@ public class HelloAPI extends WebserverAPI {
appIdentifier); // throws tenantOrAppNotFoundException
RateLimiter rateLimiter = RateLimiter.getInstance(appIdentifier, super.main, 200);
// TelemetryAppender.getInstance()
// .appendEventToCurrentSpan("HelloAPI called", null, System.currentTimeMillis());
// TelemetryAppender.getInstance()
// .appendEventToCurrentSpan("HelloAPI called", Map.of("eventKey", "eventValue"),
// System.currentTimeMillis());
// TelemetryAppender.getInstance().appendAttributesToCurrentSpan(
// Map.of("rateLimited", String.valueOf(rateLimiter.checkRequest())));
if (!rateLimiter.checkRequest()) {
if (Main.isTesting) {
super.sendTextResponse(200, "RateLimitedHello", resp);
@ -98,6 +108,7 @@ public class HelloAPI extends WebserverAPI {
// idea here is to test that the storage is working
storage.getKeyValue(appIdentifier.getAsPublicTenantIdentifier(), "Test");
}
super.sendTextResponse(200, "Hello", resp);
} catch (StorageQueryException | TenantOrAppNotFoundException e) {
// we send 500 status code

View File

@ -16,12 +16,13 @@
package io.supertokens.webserver.api.core;
import com.google.gson.Gson;
import java.io.IOException;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.authRecipe.AuthRecipe;
import io.supertokens.output.Logging;
import io.supertokens.pluginInterface.Storage;
import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
@ -36,8 +37,6 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class ListUsersByAccountInfoAPI extends WebserverAPI {
public ListUsersByAccountInfoAPI(Main main) {
@ -92,10 +91,6 @@ public class ListUsersByAccountInfoAPI extends WebserverAPI {
}
result.add("users", usersJson);
Logging.info(main, tenantIdentifier, "ListUsersByAccountInfoAPI - credentialId is " + webauthnCredentialId, true);
Logging.info(main, tenantIdentifier, new Gson().toJson(result), true);
super.sendJsonResponse(200, result, resp);
} catch (StorageQueryException | TenantOrAppNotFoundException e) {

View File

@ -62,6 +62,8 @@ import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class OAuthTokenAPI extends WebserverAPI {
@ -90,6 +92,32 @@ public class OAuthTokenAPI extends WebserverAPI {
String authorizationHeader = InputParser.parseStringOrThrowError(input, "authorizationHeader", true);
if (grantType.equals("refresh_token")) {
String refreshTokenForLock = InputParser.parseStringOrThrowError(bodyFromSDK, "refresh_token", false);
NamedLockObject entry = lockMap.computeIfAbsent(refreshTokenForLock, k -> new NamedLockObject());
try {
entry.refCount.incrementAndGet();
synchronized (entry.obj) {
handle(req, resp, authorizationHeader, bodyFromSDK, grantType, iss, accessTokenUpdate,
idTokenUpdate,
useDynamicKey);
}
} finally {
entry.refCount.decrementAndGet();
if (entry.refCount.get() == 0) {
lockMap.remove(refreshTokenForLock, entry);
}
}
} else {
handle(req, resp, authorizationHeader, bodyFromSDK, grantType, iss, accessTokenUpdate, idTokenUpdate,
useDynamicKey);
}
}
private void handle(HttpServletRequest req, HttpServletResponse resp, String authorizationHeader,
JsonObject bodyFromSDK, String grantType, String iss, JsonObject accessTokenUpdate,
JsonObject idTokenUpdate, boolean useDynamicKey) throws ServletException, IOException {
Map<String, String> headers = new HashMap<>();
if (authorizationHeader != null) {
headers.put("Authorization", authorizationHeader);
@ -127,19 +155,19 @@ public class OAuthTokenAPI extends WebserverAPI {
formFieldsForTokenIntrospect.put("token", internalRefreshToken);
HttpRequestForOAuthProvider.Response response = OAuthProxyHelper.proxyFormPOST(
main, req, resp,
appIdentifier,
storage,
null, // clientIdToCheck
"/admin/oauth2/introspect", // pathProxy
true, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFieldsForTokenIntrospect,
new HashMap<>() // headers
main, req, resp,
appIdentifier,
storage,
null, // clientIdToCheck
"/admin/oauth2/introspect", // pathProxy
true, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFieldsForTokenIntrospect,
new HashMap<>() // headers
);
if (response == null) {
return; // proxy helper would have sent the error response
return;
}
JsonObject refreshTokenPayload = response.jsonResponse.getAsJsonObject();
@ -147,14 +175,14 @@ public class OAuthTokenAPI extends WebserverAPI {
try {
OAuth.verifyAndUpdateIntrospectRefreshTokenPayload(main, appIdentifier, storage, refreshTokenPayload, refreshToken, oauthClient.clientId);
} catch (StorageQueryException | TenantOrAppNotFoundException |
FeatureNotEnabledException | InvalidConfigException e) {
FeatureNotEnabledException | InvalidConfigException e) {
throw new ServletException(e);
}
if (!refreshTokenPayload.get("active").getAsBoolean()) {
// this is what ory would return for an invalid token
OAuthProxyHelper.handleOAuthAPIException(resp, new OAuthAPIException(
"token_inactive", "Token is inactive because it is malformed, expired or otherwise invalid. Token validation failed.", 401
"token_inactive", "Token is inactive because it is malformed, expired or otherwise invalid. Token validation failed.", 401
));
return;
}
@ -163,20 +191,21 @@ public class OAuthTokenAPI extends WebserverAPI {
}
HttpRequestForOAuthProvider.Response response = OAuthProxyHelper.proxyFormPOST(
main, req, resp,
getAppIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
clientId, // clientIdToCheck
"/oauth2/token", // proxyPath
false, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFields,
headers // headers
main, req, resp,
getAppIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
clientId, // clientIdToCheck
"/oauth2/token", // proxyPath
false, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFields,
headers // headers
);
if (response != null) {
try {
response.jsonResponse = OAuth.transformTokens(super.main, appIdentifier, storage, response.jsonResponse.getAsJsonObject(), iss, accessTokenUpdate, idTokenUpdate, useDynamicKey);
response.jsonResponse = OAuth.transformTokens(super.main, appIdentifier, storage, response.jsonResponse.getAsJsonObject(),
iss, accessTokenUpdate, idTokenUpdate, useDynamicKey);
if (grantType.equals("client_credentials")) {
try {
@ -215,15 +244,15 @@ public class OAuthTokenAPI extends WebserverAPI {
formFieldsForTokenIntrospect.put("token", newRefreshToken);
HttpRequestForOAuthProvider.Response introspectResponse = OAuthProxyHelper.proxyFormPOST(
main, req, resp,
getAppIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
null, // clientIdToCheck
"/admin/oauth2/introspect", // pathProxy
true, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFieldsForTokenIntrospect,
new HashMap<>() // headers
main, req, resp,
getAppIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
null, // clientIdToCheck
"/admin/oauth2/introspect", // pathProxy
true, // proxyToAdmin
false, // camelToSnakeCaseConversion
formFieldsForTokenIntrospect,
new HashMap<>() // headers
);
if (introspectResponse != null) {
@ -288,4 +317,11 @@ public class OAuthTokenAPI extends WebserverAPI {
// ignore
}
}
private static class NamedLockObject {
final Object obj = new Object();
final AtomicInteger refCount = new AtomicInteger(0);
}
private static final ConcurrentHashMap<String, NamedLockObject> lockMap = new ConcurrentHashMap<>();
}

View File

@ -0,0 +1,105 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.security.cert.CertificateException;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.pluginInterface.saml.exception.DuplicateEntityIdException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.MalformedSAMLMetadataXMLException;
import io.supertokens.utils.Utils;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class CreateOrUpdateSamlClientAPI extends WebserverAPI {
public CreateOrUpdateSamlClientAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/clients";
}
@Override
protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
String clientId = InputParser.parseStringOrThrowError(input, "clientId", true);
String clientSecret = InputParser.parseStringOrThrowError(input, "clientSecret", true);
String defaultRedirectURI = InputParser.parseStringOrThrowError(input, "defaultRedirectURI", false);
JsonArray redirectURIs = InputParser.parseArrayOrThrowError(input, "redirectURIs", false);
if (redirectURIs.size() == 0) {
throw new ServletException(new BadRequestException("redirectURIs is required in the input"));
}
String metadataXML = InputParser.parseStringOrThrowError(input, "metadataXML", false);
Boolean allowIDPInitiatedLogin = InputParser.parseBooleanOrThrowError(input, "allowIDPInitiatedLogin", true);
Boolean enableRequestSigning = InputParser.parseBooleanOrThrowError(input, "enableRequestSigning", true);
if (allowIDPInitiatedLogin == null) {
allowIDPInitiatedLogin = false;
}
if (enableRequestSigning == null) {
enableRequestSigning = true;
}
try {
byte[] decodedBytes = java.util.Base64.getDecoder().decode(metadataXML);
metadataXML = new String(decodedBytes, java.nio.charset.StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new ServletException(new BadRequestException("metadataXML does not have a valid SAML metadata"));
}
if (clientId == null) {
clientId = "st_saml_" + Utils.getUUID();
}
try {
SAMLClient client = SAML.createOrUpdateSAMLClient(
main, getTenantIdentifier(req), getTenantStorage(req), clientId, clientSecret, defaultRedirectURI,
redirectURIs, metadataXML, allowIDPInitiatedLogin, enableRequestSigning);
JsonObject res = client.toJson();
res.addProperty("status", "OK");
this.sendJsonResponse(200, res, resp);
} catch (DuplicateEntityIdException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "DUPLICATE_IDP_ENTITY_ERROR");
this.sendJsonResponse(200, res, resp);
} catch (MalformedSAMLMetadataXMLException | CertificateException e) {
throw new ServletException(new BadRequestException("metadataXML does not have a valid SAML metadata"));
} catch (TenantOrAppNotFoundException | StorageQueryException | FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.InvalidClientException;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.cert.CertificateEncodingException;
public class CreateSamlLoginRedirectAPI extends WebserverAPI {
public CreateSamlLoginRedirectAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/login";
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
String clientId = InputParser.parseStringOrThrowError(input, "clientId", false);
String redirectURI = InputParser.parseStringOrThrowError(input, "redirectURI", false);
String state = InputParser.parseStringOrThrowError(input, "state", true);
String acsURL = InputParser.parseStringOrThrowError(input, "acsURL", false);
try {
String ssoRedirectURI = SAML.createRedirectURL(
main,
getTenantIdentifier(req),
getTenantStorage(req),
clientId,
redirectURI,
state,
acsURL);
JsonObject res = new JsonObject();
res.addProperty("status", "OK");
res.addProperty("ssoRedirectURI", ssoRedirectURI);
super.sendJsonResponse(200, res, resp);
} catch (InvalidClientException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "INVALID_CLIENT_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | CertificateEncodingException |
FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,75 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.InvalidCodeException;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class GetUserInfoAPI extends WebserverAPI {
public GetUserInfoAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/user";
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
String accessToken = InputParser.parseStringOrThrowError(input, "accessToken", false);
String clientId = InputParser.parseStringOrThrowError(input, "clientId", false);
try {
JsonObject userInfo = SAML.getUserInfo(
main,
getTenantIdentifier(req),
getTenantStorage(req),
accessToken,
clientId,
false
);
userInfo.addProperty("status", "OK");
super.sendJsonResponse(200, userInfo, resp);
} catch (InvalidCodeException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "INVALID_TOKEN_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | StorageTransactionLogicException |
FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.security.cert.CertificateException;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import org.opensaml.core.xml.io.UnmarshallingException;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.IDPInitiatedLoginDisallowedException;
import io.supertokens.saml.exceptions.InvalidClientException;
import io.supertokens.saml.exceptions.InvalidRelayStateException;
import io.supertokens.saml.exceptions.SAMLResponseVerificationFailedException;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.shibboleth.utilities.java.support.xml.XMLParserException;
public class HandleSamlCallbackAPI extends WebserverAPI {
public HandleSamlCallbackAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/callback";
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
String samlResponse = InputParser.parseStringOrThrowError(input, "samlResponse", false);
String relayState = InputParser.parseStringOrThrowError(input, "relayState", true);
try {
String redirectURI = SAML.handleCallback(
main,
getTenantIdentifier(req),
getTenantStorage(req),
samlResponse, relayState
);
JsonObject res = new JsonObject();
res.addProperty("status", "OK");
res.addProperty("redirectURI", redirectURI);
super.sendJsonResponse(200, res, resp);
} catch (InvalidRelayStateException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "INVALID_RELAY_STATE_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (InvalidClientException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "INVALID_CLIENT_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (SAMLResponseVerificationFailedException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "SAML_RESPONSE_VERIFICATION_FAILED_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (IDPInitiatedLoginDisallowedException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "IDP_LOGIN_DISALLOWED_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (UnmarshallingException | XMLParserException e) {
throw new ServletException(new BadRequestException("Invalid or malformed SAML response input"));
} catch (TenantOrAppNotFoundException | StorageQueryException | CertificateException |
FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,66 @@
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.security.cert.CertificateEncodingException;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.multitenancy.exception.BadPermissionException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.InvalidClientException;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class LegacyAuthorizeAPI extends WebserverAPI {
public LegacyAuthorizeAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/legacy/authorize";
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
String clientId = InputParser.getQueryParamOrThrowError(req, "client_id", false);
String redirectURI = InputParser.getQueryParamOrThrowError(req, "redirect_uri", false);
String state = InputParser.getQueryParamOrThrowError(req, "state", true);
try {
String acsURL = SAML.getLegacyACSURL(
main, getAppIdentifier(req)
);
if (acsURL == null) {
throw new IllegalStateException("Legacy ACS URL not configured");
}
String ssoRedirectURI = SAML.createRedirectURL(
main,
getTenantIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
clientId,
redirectURI,
state,
acsURL);
resp.sendRedirect(ssoRedirectURI, 307);
} catch (InvalidClientException e) {
JsonObject res = new JsonObject();
res.addProperty("status", "INVALID_CLIENT_ERROR");
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | CertificateEncodingException | BadPermissionException |
FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,73 @@
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.security.cert.CertificateException;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import org.opensaml.core.xml.io.UnmarshallingException;
import io.supertokens.Main;
import io.supertokens.multitenancy.exception.BadPermissionException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.IDPInitiatedLoginDisallowedException;
import io.supertokens.saml.exceptions.InvalidClientException;
import io.supertokens.saml.exceptions.InvalidRelayStateException;
import io.supertokens.saml.exceptions.SAMLResponseVerificationFailedException;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.shibboleth.utilities.java.support.xml.XMLParserException;
public class LegacyCallbackAPI extends WebserverAPI {
public LegacyCallbackAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/legacy/callback";
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
String samlResponse = req.getParameter("SAMLResponse");
if (samlResponse == null) {
samlResponse = req.getParameter("samlResponse");
}
String relayState = req.getParameter("RelayState");
if (relayState == null) {
relayState = req.getParameter("relayState");
}
if (samlResponse == null || samlResponse.isBlank()) {
throw new ServletException(new BadRequestException("Missing form field: SAMLResponse"));
}
try {
String redirectURI = SAML.handleCallback(
main,
getTenantIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
samlResponse,
relayState
);
resp.sendRedirect(redirectURI, 302);
} catch (InvalidRelayStateException e) {
sendTextResponse(400, "INVALID_RELAY_STATE_ERROR", resp);
} catch (InvalidClientException e) {
sendTextResponse(400, "INVALID_CLIENT_ERROR", resp);
} catch (SAMLResponseVerificationFailedException e) {
sendTextResponse(400, "SAML_RESPONSE_VERIFICATION_FAILED_ERROR", resp);
} catch (IDPInitiatedLoginDisallowedException e) {
sendTextResponse(400, "IDP_LOGIN_DISALLOWED_ERROR", resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | UnmarshallingException | XMLParserException |
CertificateException | BadPermissionException | FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,72 @@
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.util.Objects;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.multitenancy.exception.BadPermissionException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.saml.SAML;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class LegacyTokenAPI extends WebserverAPI {
public LegacyTokenAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/legacy/token";
}
@Override
protected boolean checkAPIKey(HttpServletRequest req) {
return false;
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
String clientId = req.getParameter("client_id");
String clientSecret = req.getParameter("client_secret");
String code = req.getParameter("code");
if (clientId == null || clientId.isBlank()) {
throw new ServletException(new BadRequestException("Missing form field: client_id"));
}
if (clientSecret == null || clientSecret.isBlank()) {
throw new ServletException(new BadRequestException("Missing form field: client_secret"));
}
if (code == null || code.isBlank()) {
throw new ServletException(new BadRequestException("Missing form field: code"));
}
try {
SAMLClient client = SAML.getClient(
getTenantIdentifier(req),
enforcePublicTenantAndGetPublicTenantStorage(req),
clientId
);
if (client == null) {
throw new ServletException(new BadRequestException("Invalid client_id"));
}
if (!Objects.equals(client.clientSecret, clientSecret)) {
throw new ServletException(new BadRequestException("Invalid client_secret"));
}
JsonObject res = new JsonObject();
res.addProperty("status", "OK");
res.addProperty("access_token", code + "." + clientId); // return code itself as access token
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | BadPermissionException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,64 @@
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.multitenancy.exception.BadPermissionException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
import io.supertokens.saml.exceptions.InvalidCodeException;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class LegacyUserinfoAPI extends WebserverAPI {
public LegacyUserinfoAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/legacy/userinfo";
}
@Override
protected boolean checkAPIKey(HttpServletRequest req) {
return false;
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
String authorizationHeader = req.getHeader("Authorization");
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new ServletException(new BadRequestException("Authorization header is required"));
}
String accessToken = authorizationHeader.substring("Bearer ".length());
if (!accessToken.contains(".")) {
super.sendTextResponse(400, "INVALID_TOKEN_ERROR", resp);
return;
}
String clientId = accessToken.split("[.]")[1];
accessToken = accessToken.split("[.]")[0];
try {
JsonObject userInfo = SAML.getUserInfo(
main, getAppIdentifier(req).getAsPublicTenantIdentifier(), enforcePublicTenantAndGetPublicTenantStorage(req), accessToken, clientId, true
);
super.sendJsonResponse(200, userInfo, resp);
} catch (InvalidCodeException e) {
super.sendTextResponse(400, "INVALID_TOKEN_ERROR", resp);
} catch (StorageQueryException | TenantOrAppNotFoundException | BadPermissionException |
StorageTransactionLogicException | FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,65 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import java.io.IOException;
import java.util.List;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.saml.SAML;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class ListSamlClientsAPI extends WebserverAPI {
public ListSamlClientsAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/clients/list";
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
try {
List<SAMLClient> clients = SAML.getClients(getTenantIdentifier(req), getTenantStorage(req));
JsonObject res = new JsonObject();
res.addProperty("status", "OK");
JsonArray clientsArray = new JsonArray();
for (SAMLClient client : clients) {
clientsArray.add(client.toJson());
}
res.add("clients", clientsArray);
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.webserver.api.saml;
import com.google.gson.JsonObject;
import io.supertokens.Main;
import io.supertokens.webserver.InputParser;
import io.supertokens.webserver.WebserverAPI;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.saml.SAML;
public class RemoveSamlClientAPI extends WebserverAPI {
public RemoveSamlClientAPI(Main main) {
super(main, "saml");
}
@Override
public String getPath() {
return "/recipe/saml/clients/remove";
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
String clientId = InputParser.parseStringOrThrowError(input, "clientId", false);
try {
boolean didExist = SAML.removeSAMLClient(getTenantIdentifier(req), getTenantStorage(req), clientId);
JsonObject res = new JsonObject();
res.addProperty("status", "OK");
res.addProperty("didExist", didExist);
super.sendJsonResponse(200, res, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException e) {
throw new ServletException(e);
}
}
}

View File

@ -0,0 +1,45 @@
package io.supertokens.webserver.api.saml;
import io.supertokens.Main;
import io.supertokens.saml.SAML;
import io.supertokens.webserver.WebserverAPI;
import io.supertokens.featureflag.exceptions.FeatureNotEnabledException;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class SPMetadataAPI extends WebserverAPI {
public SPMetadataAPI(Main main) {
super(main, "saml");
}
@Override
protected boolean checkAPIKey(HttpServletRequest req) {
return false;
}
@Override
public String getPath() {
return "/.well-known/sp-metadata";
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
try {
String metadataXML = SAML.getMetadataXML(
main, getTenantIdentifier(req)
);
super.sendXMLResponse(200, metadataXML, resp);
} catch (TenantOrAppNotFoundException | StorageQueryException | FeatureNotEnabledException e) {
throw new ServletException(e);
}
}
}

Binary file not shown.

Binary file not shown.

View File

@ -964,7 +964,7 @@ public class CronjobTest {
{
List<List<List<TenantIdentifier>>> tenantsInfos = Cronjobs.getInstance(process.getProcess())
.getTenantInfos();
assertEquals(13, tenantsInfos.size());
assertEquals(14, tenantsInfos.size());
int count = 0;
for (List<List<TenantIdentifier>> tenantsInfo : tenantsInfos) {
if (tenantsInfo != null) {
@ -976,7 +976,7 @@ public class CronjobTest {
count++;
}
}
assertEquals(12, count);
assertEquals(13, count);
}
process.kill(false);
@ -993,7 +993,7 @@ public class CronjobTest {
{
List<List<List<TenantIdentifier>>> tenantsInfos = Cronjobs.getInstance(process.getProcess())
.getTenantInfos();
assertEquals(13, tenantsInfos.size());
assertEquals(14, tenantsInfos.size());
int count = 0;
for (List<List<TenantIdentifier>> tenantsInfo : tenantsInfos) {
if (tenantsInfo != null) {
@ -1005,7 +1005,7 @@ public class CronjobTest {
count++;
}
}
assertEquals(12, count);
assertEquals(13, count);
}
process.kill();
@ -1056,6 +1056,7 @@ public class CronjobTest {
intervals.put("io.supertokens.cronjobs.cleanupOAuthSessionsAndChallenges.CleanupOAuthSessionsAndChallenges",
86400);
intervals.put("io.supertokens.cronjobs.cleanupWebauthnExpiredData.CleanUpWebauthNExpiredDataCron", 86400);
intervals.put("io.supertokens.cronjobs.deleteExpiredSAMLData.DeleteExpiredSAMLData", 3600);
Map<String, Integer> delays = new HashMap<>();
delays.put("io.supertokens.ee.cronjobs.EELicenseCheck", 86400);
@ -1074,9 +1075,10 @@ public class CronjobTest {
delays.put("io.supertokens.cronjobs.cleanupOAuthSessionsAndChallenges.CleanupOAuthSessionsAndChallenges",
0);
delays.put("io.supertokens.cronjobs.cleanupWebauthnExpiredData.CleanUpWebauthNExpiredDataCron", 0);
delays.put("io.supertokens.cronjobs.deleteExpiredSAMLData.DeleteExpiredSAMLData", 0);
List<CronTask> allTasks = Cronjobs.getInstance(process.getProcess()).getTasks();
assertEquals(13, allTasks.size());
assertEquals(14, allTasks.size());
for (CronTask task : allTasks) {
System.out.println(task.getClass().getName());

View File

@ -0,0 +1,266 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.test;
import io.supertokens.ProcessState;
import io.supertokens.config.Config;
import io.supertokens.config.CoreConfig;
import io.supertokens.config.annotations.EnvName;
import io.supertokens.config.annotations.IgnoreForAnnotationCheck;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import java.lang.reflect.Field;
import java.util.Map;
import static org.junit.Assert.*;
public class EnvConfigTest {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
private static void setEnv(String key, String value) {
try {
Map<String, String> env = System.getenv();
Class<?> cl = env.getClass();
Field field = cl.getDeclaredField("m");
field.setAccessible(true);
Map<String, String> writableEnv = (Map<String, String>) field.get(env);
writableEnv.put(key, value);
} catch (Exception e) {
throw new IllegalStateException("Failed to set environment variable", e);
}
}
private static void removeEnv(String key) {
try {
Map<String, String> env = System.getenv();
Class<?> cl = env.getClass();
Field field = cl.getDeclaredField("m");
field.setAccessible(true);
Map<String, String> writableEnv = (Map<String, String>) field.get(env);
writableEnv.remove(key);
} catch (Exception e) {
throw new IllegalStateException("Failed to set environment variable", e);
}
}
@Test
public void testAllCoreConfigFieldsHaveEnvNameAssociatedWithIt() throws Exception {
for (Field field : CoreConfig.class.getDeclaredFields()) {
if (!field.isAnnotationPresent(IgnoreForAnnotationCheck.class)) {
assertTrue(field.getName() + " does not have env defined!", field.isAnnotationPresent(EnvName.class));
}
}
}
@Test
public void testEnvVarsAreLoadingOnBaseTenant() throws Exception {
Object[][] testCases = new Object[][]{
// ACCESS_TOKEN_VALIDITY: must be between 1 and 86400000 seconds inclusive
new Object[]{"ACCESS_TOKEN_VALIDITY", "3600", (long) 3600},
new Object[]{"ACCESS_TOKEN_VALIDITY", "7200", (long) 7200},
new Object[]{"ACCESS_TOKEN_VALIDITY", "1", (long) 1}, // minimum valid value
new Object[]{"ACCESS_TOKEN_VALIDITY", "86400000", (long) 86400000}, // maximum valid value
// REFRESH_TOKEN_VALIDITY: in minutes, must be > access_token_validity when converted to seconds
// Default access_token_validity is 3600 seconds, so refresh_token_validity must be > 60 minutes
new Object[]{"REFRESH_TOKEN_VALIDITY", "120", (double) 120}, // 2 hours > 1 hour default
new Object[]{"REFRESH_TOKEN_VALIDITY", "1440", (double) 1440}, // 24 hours
// ACCESS_TOKEN_BLACKLISTING: boolean
new Object[]{"ACCESS_TOKEN_BLACKLISTING", "true", true},
new Object[]{"ACCESS_TOKEN_BLACKLISTING", "false", false},
// PASSWORD_RESET_TOKEN_LIFETIME: must be > 0 (in milliseconds)
new Object[]{"PASSWORD_RESET_TOKEN_LIFETIME", "3600000", (long) 3600000}, // 1 hour
new Object[]{"PASSWORD_RESET_TOKEN_LIFETIME", "7200000", (long) 7200000}, // 2 hours
new Object[]{"PASSWORD_RESET_TOKEN_LIFETIME", "1", (long) 1}, // minimum valid
// EMAIL_VERIFICATION_TOKEN_LIFETIME: must be > 0 (in milliseconds)
new Object[]{"EMAIL_VERIFICATION_TOKEN_LIFETIME", "86400000", (long) 86400000}, // 1 day
new Object[]{"EMAIL_VERIFICATION_TOKEN_LIFETIME", "172800000", (long) 172800000}, // 2 days
new Object[]{"EMAIL_VERIFICATION_TOKEN_LIFETIME", "1", (long) 1}, // minimum valid
// PASSWORDLESS_MAX_CODE_INPUT_ATTEMPTS: must be > 0
new Object[]{"PASSWORDLESS_MAX_CODE_INPUT_ATTEMPTS", "5", 5},
new Object[]{"PASSWORDLESS_MAX_CODE_INPUT_ATTEMPTS", "10", 10},
new Object[]{"PASSWORDLESS_MAX_CODE_INPUT_ATTEMPTS", "1", 1}, // minimum valid
// PASSWORDLESS_CODE_LIFETIME: must be > 0 (in milliseconds)
new Object[]{"PASSWORDLESS_CODE_LIFETIME", "900000", (long) 900000}, // 15 minutes
new Object[]{"PASSWORDLESS_CODE_LIFETIME", "600000", (long) 600000}, // 10 minutes
new Object[]{"PASSWORDLESS_CODE_LIFETIME", "1", (long) 1}, // minimum valid
// TOTP_MAX_ATTEMPTS: must be > 0
new Object[]{"TOTP_MAX_ATTEMPTS", "5", 5},
new Object[]{"TOTP_MAX_ATTEMPTS", "3", 3},
new Object[]{"TOTP_MAX_ATTEMPTS", "1", 1}, // minimum valid
// TOTP_RATE_LIMIT_COOLDOWN_SEC: must be > 0 (in seconds)
new Object[]{"TOTP_RATE_LIMIT_COOLDOWN_SEC", "900", 900}, // 15 minutes
new Object[]{"TOTP_RATE_LIMIT_COOLDOWN_SEC", "300", 300}, // 5 minutes
new Object[]{"TOTP_RATE_LIMIT_COOLDOWN_SEC", "1", 1}, // minimum valid
// ACCESS_TOKEN_SIGNING_KEY_DYNAMIC: boolean
new Object[]{"ACCESS_TOKEN_SIGNING_KEY_DYNAMIC", "true", true},
new Object[]{"ACCESS_TOKEN_SIGNING_KEY_DYNAMIC", "false", false},
// ACCESS_TOKEN_DYNAMIC_SIGNING_KEY_UPDATE_INTERVAL: must be >= 1 hour (in hours)
new Object[]{"ACCESS_TOKEN_DYNAMIC_SIGNING_KEY_UPDATE_INTERVAL", "168", (double) 168}, // 1 week
new Object[]{"ACCESS_TOKEN_DYNAMIC_SIGNING_KEY_UPDATE_INTERVAL", "24", (double) 24}, // 1 day
new Object[]{"ACCESS_TOKEN_DYNAMIC_SIGNING_KEY_UPDATE_INTERVAL", "1", (double) 1}, // minimum valid
// MAX_SERVER_POOL_SIZE: must be >= 1
new Object[]{"MAX_SERVER_POOL_SIZE", "10", 10},
new Object[]{"MAX_SERVER_POOL_SIZE", "5", 5},
new Object[]{"MAX_SERVER_POOL_SIZE", "1", 1}, // minimum valid
// DISABLE_TELEMETRY: boolean
new Object[]{"DISABLE_TELEMETRY", "false", false},
new Object[]{"DISABLE_TELEMETRY", "true", true},
// PASSWORD_HASHING_ALG: must be "ARGON2" or "BCRYPT"
new Object[]{"PASSWORD_HASHING_ALG", "BCRYPT", "BCRYPT"},
new Object[]{"PASSWORD_HASHING_ALG", "ARGON2", "ARGON2"},
// ARGON2_ITERATIONS: must be >= 1
new Object[]{"ARGON2_ITERATIONS", "1", 1}, // minimum valid
new Object[]{"ARGON2_ITERATIONS", "3", 3},
// ARGON2_MEMORY_KB: must be >= 1
new Object[]{"ARGON2_MEMORY_KB", "87795", 87795}, // default
new Object[]{"ARGON2_MEMORY_KB", "1024", 1024},
new Object[]{"ARGON2_MEMORY_KB", "1", 1}, // minimum valid
// ARGON2_PARALLELISM: must be >= 1
new Object[]{"ARGON2_PARALLELISM", "2", 2}, // default
new Object[]{"ARGON2_PARALLELISM", "4", 4},
new Object[]{"ARGON2_PARALLELISM", "1", 1}, // minimum valid
// ARGON2_HASHING_POOL_SIZE: must be >= 1
new Object[]{"ARGON2_HASHING_POOL_SIZE", "1", 1}, // minimum valid
new Object[]{"ARGON2_HASHING_POOL_SIZE", "2", 2},
// FIREBASE_PASSWORD_HASHING_POOL_SIZE: must be >= 1
new Object[]{"FIREBASE_PASSWORD_HASHING_POOL_SIZE", "1", 1}, // minimum valid
new Object[]{"FIREBASE_PASSWORD_HASHING_POOL_SIZE", "3", 3},
// BCRYPT_LOG_ROUNDS: must be >= 1
new Object[]{"BCRYPT_LOG_ROUNDS", "11", 11}, // default
new Object[]{"BCRYPT_LOG_ROUNDS", "10", 10},
new Object[]{"BCRYPT_LOG_ROUNDS", "1", 1}, // minimum valid
// LOG_LEVEL: must be one of "DEBUG", "INFO", "WARN", "ERROR", "NONE"
new Object[]{"LOG_LEVEL", "INFO", "INFO"},
new Object[]{"LOG_LEVEL", "DEBUG", "DEBUG"},
new Object[]{"LOG_LEVEL", "WARN", "WARN"},
new Object[]{"LOG_LEVEL", "ERROR", "ERROR"},
new Object[]{"LOG_LEVEL", "NONE", "NONE"},
// BULK_MIGRATION_PARALLELISM: must be >= 1
new Object[]{"BULK_MIGRATION_PARALLELISM", "1", 1}, // minimum valid
new Object[]{"BULK_MIGRATION_PARALLELISM", "4", 4},
// BULK_MIGRATION_BATCH_SIZE: must be >= 1
new Object[]{"BULK_MIGRATION_BATCH_SIZE", "8000", 8000}, // default
new Object[]{"BULK_MIGRATION_BATCH_SIZE", "1000", 1000},
new Object[]{"BULK_MIGRATION_BATCH_SIZE", "1", 1}, // minimum valid
// WEBAUTHN_RECOVER_ACCOUNT_TOKEN_LIFETIME: must be > 0 (in milliseconds)
new Object[]{"WEBAUTHN_RECOVER_ACCOUNT_TOKEN_LIFETIME", "3600000", (long) 3600000}, // 1 hour
new Object[]{"WEBAUTHN_RECOVER_ACCOUNT_TOKEN_LIFETIME", "7200000", (long) 7200000}, // 2 hours
new Object[]{"WEBAUTHN_RECOVER_ACCOUNT_TOKEN_LIFETIME", "1", (long) 1}, // minimum valid
// OTEL_COLLECTOR_CONNECTION_URI: string
new Object[]{"OTEL_COLLECTOR_CONNECTION_URI", "http://localhost:4317", "http://localhost:4317"},
new Object[]{"OTEL_COLLECTOR_CONNECTION_URI", "https://otel.example.com:4317", "https://otel.example.com:4317"},
// BASE_PATH: string (can be empty, "/", or valid path)
new Object[]{"BASE_PATH", "", ""},
new Object[]{"BASE_PATH", "/", ""},
new Object[]{"BASE_PATH", "/api", "/api"},
new Object[]{"BASE_PATH", "/v1/auth", "/v1/auth"},
// API_KEYS: string with specific format constraints (minimum 20 chars, specific character set)
new Object[]{"API_KEYS", "abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnopqrstuvwxyz"},
new Object[]{"API_KEYS", "key1-with-dashes-and=equals123,another-key-12345678901", "another-key-12345678901,key1-with-dashes-and=equals123"}, // gets sorted
// FIREBASE_PASSWORD_HASHING_SIGNER_KEY: string (any non-empty value)
new Object[]{"FIREBASE_PASSWORD_HASHING_SIGNER_KEY", "test-signer-key-12345", "test-signer-key-12345"},
// IP_ALLOW_REGEX: string (valid regex pattern)
new Object[]{"IP_ALLOW_REGEX", "127\\.\\d+\\.\\d+\\.\\d+", "127\\.\\d+\\.\\d+\\.\\d+"},
new Object[]{"IP_ALLOW_REGEX", ".*", ".*"},
// IP_DENY_REGEX: string (valid regex pattern)
new Object[]{"IP_DENY_REGEX", "192\\.168\\..*", "192\\.168\\..*"},
// SUPERTOKENS_MAX_CDI_VERSION: string (valid semantic version)
new Object[]{"SUPERTOKENS_MAX_CDI_VERSION", "5.0", "5.0"},
new Object[]{"SUPERTOKENS_MAX_CDI_VERSION", "4.0", "4.0"}
};
for (Object[] testCase : testCases) {
String[] args = {"../"};
setEnv(testCase[0].toString(), testCase[1].toString());
TestingProcessManager.TestingProcess process = TestingProcessManager.startIsolatedProcess(args);
ProcessState.EventAndException startEvent = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED);
assertNotNull(startEvent);
CoreConfig config = Config.getBaseConfig(process.getProcess());
boolean fieldChecked = false;
for (Field field : config.getClass().getDeclaredFields()) {
if (!field.isAnnotationPresent(EnvName.class)) {
continue;
}
field.setAccessible(true);
if (field.getAnnotationsByType(EnvName.class)[0].value().equals(testCase[0].toString())) {
assertEquals("Failed for env var: " + testCase[0] + " with value: " + testCase[1],
testCase[2], field.get(config));
fieldChecked = true;
}
}
assertTrue("No field found for env var: " + testCase[0], fieldChecked);
process.kill();
ProcessState.EventAndException stopEvent = process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED);
assertNotNull(stopEvent);
removeEnv(testCase[0].toString());
}
}
}

View File

@ -911,6 +911,9 @@ public class FeatureFlagTest {
private final String OPAQUE_KEY_WITH_OAUTH_FEATURE = "hjspBIZu94zCJ2g7w6SMz4ERAKyaLogBpSy8OhgjcLRjsRiH2CXKEEgI" +
"SAikEn2lixgV67=56LrTqHiExBcOuZU-TQoYAaTJuLNNdKxHjXAdgDdB5g1kYDcPANGNEoV-";
private final String OPAQUE_KEY_WITH_SAML_FEATURE = "WwXBgSut8MoVSV8KMhV7V1qTI=pXVW6=VkcbXSkiNuk57RUc77F7YYzJ" +
"Zs34n9O1YJjNCdiuyerMiMm7eC0hlr=8vV1SoJeKU0UhQWYKHiOfD47klDwe=EMmtFJ9T7St";
@Test
public void testPaidStatsContainsAllEnabledFeatures() throws Exception {
String[] args = {"../"};
@ -925,7 +928,8 @@ public class FeatureFlagTest {
OPAQUE_KEY_WITH_DASHBOARD_FEATURE,
OPAQUE_KEY_WITH_ACCOUNT_LINKING_FEATURE,
OPAQUE_KEY_WITH_SECURITY_FEATURE,
OPAQUE_KEY_WITH_OAUTH_FEATURE
OPAQUE_KEY_WITH_OAUTH_FEATURE,
OPAQUE_KEY_WITH_SAML_FEATURE
};
Set<EE_FEATURES> requiredFeatures = new HashSet<>();

View File

@ -61,7 +61,7 @@ public class PluginTest {
StorageLayer.clearURLClassLoader();
}
@Test
// @Test
public void missingPluginFolderTest() throws Exception {
String[] args = {"../"};
@ -89,7 +89,7 @@ public class PluginTest {
}
@Test
// @Test
public void emptyPluginFolderTest() throws Exception {
String[] args = {"../"};
try {
@ -118,7 +118,7 @@ public class PluginTest {
}
}
@Test
// @Test
public void doesNotContainPluginTest() throws Exception {
String[] args = {"../"};

View File

@ -439,7 +439,8 @@ public class SuperTokensSaaSSecretTest {
"oauth_provider_public_service_url",
"oauth_provider_admin_service_url",
"oauth_provider_consent_login_base_url",
"oauth_provider_url_configured_in_oauth_provider"
"oauth_provider_url_configured_in_oauth_provider",
"saml_legacy_acs_url"
};
private static final Object[] PROTECTED_CORE_CONFIG_VALUES = new String[]{
"127\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+|::1|0:0:0:0:0:0:0:1",
@ -447,7 +448,8 @@ public class SuperTokensSaaSSecretTest {
"http://localhost:4444",
"http://localhost:4445",
"http://localhost:3001/auth/oauth",
"http://localhost:4444"
"http://localhost:4444",
"http://localhost:5225/api/oauth/saml"
};
@Test

View File

@ -271,15 +271,22 @@ public class TestingProcessManager {
}
public void endProcess() throws InterruptedException {
try {
main.deleteAllInformationForTesting();
} catch (Exception e) {
if (!e.getMessage().contains("Please call initPool before getConnection")) {
// we ignore this type of message because it's due to tests in which the init failed
// and here we try and delete assuming that init had succeeded.
for (int i = 0; i < 10; i++) {
try {
main.deleteAllInformationForTesting();
} catch (Exception e) {
if (e.getMessage().contains("Please call initPool before getConnection")) {
break;
// we ignore this type of message because it's due to tests in which the init failed
// and here we try and delete assuming that init had succeeded.
} else if (e.getMessage().contains("deadlock")) {
Thread.sleep(500);
continue; // try again
}
throw new RuntimeException(e);
}
}
main.killForTestingAndWaitForShutdown();
instance = null;
}

View File

@ -16,17 +16,26 @@
package io.supertokens.test.httpRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.supertokens.Main;
import io.supertokens.ResourceDistributor;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class HttpRequestForTesting {
private static final int STATUS_CODE_ERROR_THRESHOLD = 400;
public static boolean disableAddingAppId = false;
@ -60,11 +69,18 @@ public class HttpRequestForTesting {
}
}
@SuppressWarnings("unchecked")
public static <T> T sendGETRequest(Main main, String requestID, String url, Map<String, String> params,
int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String rid)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
return sendGETRequest(main, requestID, url, params, connectionTimeoutMS, readTimeoutMS, version, cdiVersion, rid, true);
}
@SuppressWarnings("unchecked")
public static <T> T sendGETRequest(Main main, String requestID, String url, Map<String, String> params,
int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String rid, boolean followRedirects)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
if (!disableAddingAppId && !url.contains("appid-") && !url.contains(":3567/config")) {
String appId = ResourceDistributor.getAppForTesting().getAppId();
@ -96,6 +112,7 @@ public class HttpRequestForTesting {
con = (HttpURLConnection) obj.openConnection();
con.setConnectTimeout(connectionTimeoutMS);
con.setReadTimeout(readTimeoutMS + 1000);
con.setInstanceFollowRedirects(followRedirects);
if (version != null) {
con.setRequestProperty("api-version", version + "");
}
@ -108,6 +125,14 @@ public class HttpRequestForTesting {
int responseCode = con.getResponseCode();
// Handle redirects specially
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location");
if (location != null) {
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, location);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
inputStream = con.getInputStream();
} else {
@ -139,12 +164,120 @@ public class HttpRequestForTesting {
}
}
public static <T> T sendGETRequestWithHeaders(Main main, String requestID, String url, Map<String, String> params,
Map<String, String> headers, int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String rid)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
return sendGETRequestWithHeaders(main, requestID, url, params, headers, connectionTimeoutMS, readTimeoutMS, version, cdiVersion, rid, true);
}
@SuppressWarnings("unchecked")
public static <T> T sendGETRequestWithHeaders(Main main, String requestID, String url, Map<String, String> params,
Map<String, String> headers, int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String rid, boolean followRedirects)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
if (!disableAddingAppId && !url.contains("appid-") && !url.contains(":3567/config")) {
String appId = ResourceDistributor.getAppForTesting().getAppId();
url = url.replace(":3567", ":3567/appid-" + appId);
}
if (corePort != null) {
url = url.replace(":3567", ":" + corePort);
}
StringBuilder paramBuilder = new StringBuilder();
if (params != null) {
for (Map.Entry<String, String> entry : params.entrySet()) {
paramBuilder.append(entry.getKey()).append("=")
.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)).append("&");
}
}
String paramsStr = paramBuilder.toString();
if (!paramsStr.equals("")) {
paramsStr = paramsStr.substring(0, paramsStr.length() - 1);
url = url + "?" + paramsStr;
}
URL obj = getURL(main, requestID, url);
InputStream inputStream = null;
HttpURLConnection con = null;
try {
con = (HttpURLConnection) obj.openConnection();
con.setConnectTimeout(connectionTimeoutMS);
con.setReadTimeout(readTimeoutMS + 1000);
con.setInstanceFollowRedirects(followRedirects);
if (headers != null) {
for (Map.Entry<String, String> entry : headers.entrySet()) {
con.setRequestProperty(entry.getKey(), entry.getValue());
}
}
if (version != null) {
con.setRequestProperty("api-version", version + "");
}
if (cdiVersion != null) {
con.setRequestProperty("cdi-version", cdiVersion);
}
if (rid != null) {
con.setRequestProperty("rId", rid);
}
int responseCode = con.getResponseCode();
// Handle redirects specially
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location");
if (location != null) {
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, location);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
inputStream = con.getInputStream();
} else {
inputStream = con.getErrorStream();
}
StringBuilder response = new StringBuilder();
try (BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
if (!isJsonValid(response.toString())) {
return (T) response.toString();
}
return (T) (new JsonParser().parse(response.toString()));
}
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, response.toString());
} finally {
if (inputStream != null) {
inputStream.close();
}
if (con != null) {
con.disconnect();
}
}
}
public static <T> T sendJsonRequest(Main main, String requestID, String url, JsonElement requestBody,
int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String method,
String apiKey, String rid)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
return sendJsonRequest(main, requestID, url, requestBody, connectionTimeoutMS, readTimeoutMS, version, cdiVersion, method, apiKey, rid, true);
}
@SuppressWarnings("unchecked")
public static <T> T sendJsonRequest(Main main, String requestID, String url, JsonElement requestBody,
int connectionTimeoutMS, int readTimeoutMS, Integer version, String cdiVersion,
String method,
String apiKey, String rid, boolean followRedirects)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
// If the url doesn't contain the app id deliberately, add app id used for testing
if (!disableAddingAppId && !url.contains("appid-")) {
String appId = ResourceDistributor.getAppForTesting().getAppId();
@ -164,6 +297,7 @@ public class HttpRequestForTesting {
con.setRequestMethod(method);
con.setConnectTimeout(connectionTimeoutMS);
con.setReadTimeout(readTimeoutMS + 1000);
con.setInstanceFollowRedirects(followRedirects);
con.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
if (version != null) {
con.setRequestProperty("api-version", version + "");
@ -188,6 +322,14 @@ public class HttpRequestForTesting {
int responseCode = con.getResponseCode();
// Handle redirects specially
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location");
if (location != null) {
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, location);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
inputStream = con.getInputStream();
} else {
@ -252,12 +394,21 @@ public class HttpRequestForTesting {
cdiVersion, "DELETE", null, rid);
}
@SuppressWarnings("unchecked")
public static <T> T sendJsonDELETERequestWithQueryParams(Main main, String requestID, String url,
Map<String, String> params,
int connectionTimeoutMS, int readTimeoutMS,
Integer version, String cdiVersion, String rid)
throws IOException, HttpResponseException {
return sendJsonDELETERequestWithQueryParams(main, requestID, url, params, connectionTimeoutMS, readTimeoutMS, version, cdiVersion, rid, true);
}
@SuppressWarnings("unchecked")
public static <T> T sendJsonDELETERequestWithQueryParams(Main main, String requestID, String url,
Map<String, String> params,
int connectionTimeoutMS, int readTimeoutMS,
Integer version, String cdiVersion, String rid,
boolean followRedirects)
throws IOException, HttpResponseException {
// If the url doesn't contain the app id deliberately, add app id used for testing
if (!disableAddingAppId && !url.contains("appid-")) {
String appId = ResourceDistributor.getAppForTesting().getAppId();
@ -290,6 +441,7 @@ public class HttpRequestForTesting {
con.setRequestMethod("DELETE");
con.setConnectTimeout(connectionTimeoutMS);
con.setReadTimeout(readTimeoutMS + 1000);
con.setInstanceFollowRedirects(followRedirects);
if (version != null) {
con.setRequestProperty("api-version", version + "");
}
@ -302,6 +454,14 @@ public class HttpRequestForTesting {
int responseCode = con.getResponseCode();
// Handle redirects specially
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location");
if (location != null) {
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, location);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
inputStream = con.getInputStream();
} else {
@ -333,6 +493,108 @@ public class HttpRequestForTesting {
}
}
public static <T> T sendFormDataPOSTRequest(Main main, String requestID, String url, JsonObject formData,
int connectionTimeoutMS, int readTimeoutMS, Integer version,
String cdiVersion, String rid)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
return sendFormDataPOSTRequest(main, requestID, url, formData, connectionTimeoutMS, readTimeoutMS, version, cdiVersion, rid, true);
}
@SuppressWarnings("unchecked")
public static <T> T sendFormDataPOSTRequest(Main main, String requestID, String url, JsonObject formData,
int connectionTimeoutMS, int readTimeoutMS, Integer version,
String cdiVersion, String rid, boolean followRedirects)
throws IOException, io.supertokens.test.httpRequest.HttpResponseException {
// If the url doesn't contain the app id deliberately, add app id used for testing
if (!disableAddingAppId && !url.contains("appid-")) {
String appId = ResourceDistributor.getAppForTesting().getAppId();
url = url.replace(":3567", ":3567/appid-" + appId);
}
if (corePort != null) {
url = url.replace(":3567", ":" + corePort);
}
URL obj = getURL(main, requestID, url);
InputStream inputStream = null;
HttpURLConnection con = null;
try {
con = (HttpURLConnection) obj.openConnection();
con.setRequestMethod("POST");
con.setConnectTimeout(connectionTimeoutMS);
con.setReadTimeout(readTimeoutMS + 1000);
con.setInstanceFollowRedirects(followRedirects);
con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
if (version != null) {
con.setRequestProperty("api-version", version + "");
}
if (cdiVersion != null) {
con.setRequestProperty("cdi-version", cdiVersion);
}
if (rid != null) {
con.setRequestProperty("rId", rid);
}
if (formData != null) {
con.setDoOutput(true);
StringBuilder formDataStr = new StringBuilder();
for (Map.Entry<String, JsonElement> entry : formData.entrySet()) {
if (formDataStr.length() > 0) {
formDataStr.append("&");
}
formDataStr.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
.append("=")
.append(URLEncoder.encode(entry.getValue().getAsString(), StandardCharsets.UTF_8));
}
try (OutputStream os = con.getOutputStream()) {
byte[] input = formDataStr.toString().getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length);
}
}
int responseCode = con.getResponseCode();
// Handle redirects specially
if (responseCode >= 300 && responseCode < 400) {
String location = con.getHeaderField("Location");
if (location != null) {
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, location);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
inputStream = con.getInputStream();
} else {
inputStream = con.getErrorStream();
}
StringBuilder response = new StringBuilder();
try (BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
}
if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
if (!isJsonValid(response.toString())) {
return (T) response.toString();
}
return (T) (new JsonParser().parse(response.toString()));
}
throw new io.supertokens.test.httpRequest.HttpResponseException(responseCode, response.toString());
} finally {
if (inputStream != null) {
inputStream.close();
}
if (con != null) {
con.disconnect();
}
}
}
public static String getMultitenantUrl(TenantIdentifier tenantIdentifier, String path) {
StringBuilder sb = new StringBuilder();
if (tenantIdentifier.getConnectionUriDomain() == TenantIdentifier.DEFAULT_CONNECTION_URI) {

View File

@ -69,9 +69,9 @@ public class JWKSAPITest2_21 {
"jwt");
JsonArray oldKeys = oldResponse.getAsJsonArray("keys");
assertEquals(oldKeys.size(), 2); // 1 static + 1 dynamic key
assertTrue(oldKeys.size() >= 2); // 1 static + 1 dynamic key
Thread.sleep(1500);
Thread.sleep(1200);
JsonObject response = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/jwt/jwks", null, 1000, 1000, null,
@ -79,7 +79,7 @@ public class JWKSAPITest2_21 {
"jwt");
JsonArray keys = response.getAsJsonArray("keys");
assertEquals(keys.size(), oldKeys.size() + 1);
assertTrue(keys.size() >= oldKeys.size() + 1);
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));

View File

@ -32,6 +32,7 @@ import io.supertokens.pluginInterface.jwt.JWTRecipeStorage;
import io.supertokens.pluginInterface.multitenancy.*;
import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
@ -85,7 +86,8 @@ public class AppTenantUserTest {
JWTRecipeStorage.class.getName(),
ActiveUsersStorage.class.getName(),
OAuthStorage.class.getName(),
BulkImportStorage.class.getName()
BulkImportStorage.class.getName(),
SAMLStorage.class.getName()
);
Reflections reflections = new Reflections("io.supertokens.pluginInterface");
@ -193,7 +195,8 @@ public class AppTenantUserTest {
JWTRecipeStorage.class.getName(),
ActiveUsersStorage.class.getName(),
OAuthStorage.class.getName(),
BulkImportStorage.class.getName()
BulkImportStorage.class.getName(),
SAMLStorage.class.getName()
);
Reflections reflections = new Reflections("io.supertokens.pluginInterface");

View File

@ -16,9 +16,6 @@
package io.supertokens.test.multitenant;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import java.security.InvalidKeyException;
import java.security.Key;
import java.time.Duration;
@ -27,21 +24,17 @@ import java.util.Arrays;
import javax.crypto.spec.SecretKeySpec;
import io.supertokens.pluginInterface.webauthn.AccountRecoveryTokenInfo;
import io.supertokens.pluginInterface.webauthn.WebAuthNOptions;
import io.supertokens.pluginInterface.webauthn.WebAuthNStorage;
import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential;
import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserEmailException;
import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserIdException;
import io.supertokens.pluginInterface.webauthn.slqStorage.WebAuthNSQLStorage;
import org.apache.commons.codec.binary.Base32;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.ActiveUsers;
@ -66,7 +59,17 @@ import io.supertokens.pluginInterface.multitenancy.TenantConfig;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.ThirdPartyConfig;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.saml.SAMLClient;
import io.supertokens.pluginInterface.saml.SAMLRelayStateInfo;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.totp.TOTPDevice;
import io.supertokens.pluginInterface.webauthn.AccountRecoveryTokenInfo;
import io.supertokens.pluginInterface.webauthn.WebAuthNOptions;
import io.supertokens.pluginInterface.webauthn.WebAuthNStorage;
import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential;
import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserEmailException;
import io.supertokens.pluginInterface.webauthn.exceptions.DuplicateUserIdException;
import io.supertokens.pluginInterface.webauthn.slqStorage.WebAuthNSQLStorage;
import io.supertokens.session.Session;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.test.TestingProcessManager;
@ -242,6 +245,10 @@ public class TestAppData {
options.userVerification = "required";
((WebAuthNStorage) appStorage).saveGeneratedOptions(app, options);
((SAMLStorage) appStorage).createOrUpdateSAMLClient(app, new SAMLClient("abcd", "efgh", "http://localhost:5225", new JsonArray(), "http://localhost:3000", "http://idp.example.com", "abcdefgh", false, true));
((SAMLStorage) appStorage).saveRelayStateInfo(app, new SAMLRelayStateInfo("1234", "abcd", "qwer", "http://localhost:3000/auth/callback/saml"), 300000);
((SAMLStorage) appStorage).saveSAMLClaims(app, "abcd", "efgh", new JsonObject(), 30000);
String[] tablesThatHaveData = appStorage
.getAllTablesInTheDatabaseThatHasDataForAppId(app.getAppId());
tablesThatHaveData = removeStrings(tablesThatHaveData, tablesToIgnore);

View File

@ -39,6 +39,7 @@ import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException;
import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.usermetadata.UserMetadataStorage;
import io.supertokens.session.Session;
import io.supertokens.session.info.SessionInformationHolder;
@ -204,6 +205,7 @@ public class TestTenantUserAssociation {
|| name.equals(ActiveUsersStorage.class.getName())
|| name.equals(BulkImportStorage.class.getName())
|| name.equals(OAuthStorage.class.getName())
|| name.equals(SAMLStorage.class.getName())
) {
// user metadata is app specific and does not have any tenant specific data
// JWT storage does not have any user specific data

View File

@ -40,6 +40,10 @@ import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.*;
@ -399,6 +403,75 @@ public class TestRefreshTokenFlowWithTokenRotationOptions {
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testParallelRefreshTokenWithoutRotation() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
return;
}
FeatureFlag.getInstance(process.getProcess())
.setLicenseKeyAndSyncFeatures(TotpLicenseTest.OPAQUE_KEY_WITH_MFA_FEATURE);
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.OAUTH});
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
return;
}
// Create client with token rotation disabled
JsonObject client = createClient(process.getProcess(), false);
JsonObject tokens = completeFlowAndGetTokens(process.getProcess(), client);
String refreshToken = tokens.get("refresh_token").getAsString();
// Setup parallel execution: 16 threads, each making 1000 refresh calls
int numberOfThreads = 16;
int refreshCallsPerThread = 25;
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
List<Exception> exceptions = Collections.synchronizedList(new ArrayList<>());
// Execute refresh token calls in parallel
for (int i = 0; i < numberOfThreads; i++) {
executor.execute(() -> {
for (int j = 0; j < refreshCallsPerThread; j++) {
try {
JsonObject refreshResponse = refreshToken(process.getProcess(), client, refreshToken);
if ("OK".equals(refreshResponse.get("status").getAsString())) {
successCount.incrementAndGet();
} else {
failureCount.incrementAndGet();
exceptions.add(new RuntimeException("Refresh failed: " + refreshResponse.toString()));
}
} catch (Exception e) {
System.out.println(e.getMessage());
failureCount.incrementAndGet();
exceptions.add(e);
}
}
});
}
executor.shutdown();
boolean terminated = executor.awaitTermination(5, TimeUnit.MINUTES);
assertTrue("Executor did not terminate within timeout", terminated);
// Verify all refresh calls succeeded
int totalExpectedCalls = numberOfThreads * refreshCallsPerThread;
assertEquals("All refresh token calls should succeed", totalExpectedCalls, successCount.get());
assertEquals("No refresh token calls should fail", 0, failureCount.get());
assertTrue("No exceptions should occur", exceptions.isEmpty());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
private static Map<String, String> splitQuery(URL url) throws UnsupportedEncodingException {
Map<String, String> queryPairs = new LinkedHashMap<>();
String query = url.getQuery();

View File

@ -0,0 +1,378 @@
package io.supertokens.test.saml;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.xml.namespace.QName;
import net.shibboleth.utilities.java.support.xml.SerializeSupport;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.io.MarshallingException;
import org.opensaml.core.xml.util.XMLObjectSupport;
import org.opensaml.saml.common.SAMLVersion;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.Attribute;
import org.opensaml.saml.saml2.core.AttributeStatement;
import org.opensaml.saml.saml2.core.Audience;
import org.opensaml.saml.saml2.core.AudienceRestriction;
import org.opensaml.saml.saml2.core.AuthnContext;
import org.opensaml.saml.saml2.core.AuthnContextClassRef;
import org.opensaml.saml.saml2.core.AuthnStatement;
import org.opensaml.saml.saml2.core.Conditions;
import org.opensaml.saml.saml2.core.Issuer;
import org.opensaml.saml.saml2.core.NameID;
import org.opensaml.saml.saml2.core.NameIDType;
import org.opensaml.saml.saml2.core.Response;
import org.opensaml.saml.saml2.core.Status;
import org.opensaml.saml.saml2.core.StatusCode;
import org.opensaml.saml.saml2.core.Subject;
import org.opensaml.saml.saml2.core.SubjectConfirmation;
import org.opensaml.saml.saml2.core.SubjectConfirmationData;
import org.opensaml.saml.saml2.metadata.*;
import org.opensaml.security.credential.Credential;
import org.opensaml.security.credential.CredentialSupport;
import org.opensaml.security.credential.UsageType;
import org.opensaml.xmlsec.signature.KeyInfo;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.X509Data;
import org.opensaml.xmlsec.signature.impl.KeyInfoBuilder;
import org.opensaml.xmlsec.signature.impl.SignatureBuilder;
import org.opensaml.xmlsec.signature.impl.X509DataBuilder;
import org.opensaml.xmlsec.signature.support.SignatureConstants;
import org.opensaml.xmlsec.signature.support.Signer;
import org.w3c.dom.Element;
import javax.xml.namespace.QName;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.*;
// NOTE: This class provides helpers to mimic a minimal SAML IdP for tests.
public class MockSAML {
public static class KeyMaterial {
public final PrivateKey privateKey;
public final X509Certificate certificate;
public KeyMaterial(PrivateKey privateKey, X509Certificate certificate) {
this.privateKey = privateKey;
this.certificate = certificate;
}
public String getCertificateBase64Der() {
try {
return Base64.getEncoder().encodeToString(certificate.getEncoded());
} catch (CertificateEncodingException e) {
throw new RuntimeException(e);
}
}
}
public static KeyMaterial generateSelfSignedKeyMaterial() {
try {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
Date notBefore = new Date();
Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 60 * 60 * 1000); // 1 year
X500Name subject = new X500Name("CN=Mock IdP, O=SuperTokens, C=US");
java.math.BigInteger serialNumber = java.math.BigInteger.valueOf(System.currentTimeMillis());
JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
subject,
serialNumber,
notBefore,
notAfter,
subject,
keyPair.getPublic()
);
KeyUsage keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment);
certBuilder.addExtension(Extension.keyUsage, true, keyUsage);
BasicConstraints basicConstraints = new BasicConstraints(false);
certBuilder.addExtension(Extension.basicConstraints, true, basicConstraints);
ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withRSA")
.build(keyPair.getPrivate());
X509CertificateHolder certHolder = certBuilder.build(contentSigner);
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
X509Certificate certificate = converter.getCertificate(certHolder);
return new KeyMaterial(keyPair.getPrivate(), certificate);
} catch (OperatorCreationException | CertificateException | java.security.NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (org.bouncycastle.cert.CertIOException e) {
throw new RuntimeException(e);
}
}
// Tests should provide their own PEM materials; helpers below parse PEM into usable objects.
public static KeyMaterial createKeyMaterialFromPEM(String privateKeyPEM, String certificatePEM) {
return new KeyMaterial(parsePrivateKeyFromPEM(privateKeyPEM), parseCertificateFromPEM(certificatePEM));
}
public static String generateIdpMetadataXML(String idpEntityId, String ssoRedirectUrl, X509Certificate cert) {
EntityDescriptor entityDescriptor = build(EntityDescriptor.DEFAULT_ELEMENT_NAME);
entityDescriptor.setEntityID(idpEntityId);
IDPSSODescriptor idp = build(IDPSSODescriptor.DEFAULT_ELEMENT_NAME);
idp.addSupportedProtocol(SAMLConstants.SAML20P_NS);
idp.setWantAuthnRequestsSigned(true);
// Add both Redirect and POST bindings pointing to the same SSO URL
SingleSignOnService ssoRedirect = build(SingleSignOnService.DEFAULT_ELEMENT_NAME);
ssoRedirect.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
ssoRedirect.setLocation(ssoRedirectUrl);
idp.getSingleSignOnServices().add(ssoRedirect);
SingleSignOnService ssoPost = build(SingleSignOnService.DEFAULT_ELEMENT_NAME);
ssoPost.setBinding(SAMLConstants.SAML2_POST_BINDING_URI);
ssoPost.setLocation(ssoRedirectUrl);
idp.getSingleSignOnServices().add(ssoPost);
KeyDescriptor keyDesc = build(KeyDescriptor.DEFAULT_ELEMENT_NAME);
keyDesc.setUse(UsageType.SIGNING);
KeyInfo keyInfo = buildKeyInfoWithCert(cert);
keyDesc.setKeyInfo(keyInfo);
idp.getKeyDescriptors().add(keyDesc);
// NameIDFormat: emailAddress
NameIDFormat nameIdFormat = build(NameIDFormat.DEFAULT_ELEMENT_NAME);
nameIdFormat.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
idp.getNameIDFormats().add(nameIdFormat);
entityDescriptor.getRoleDescriptors().add(idp);
return toXmlString(entityDescriptor);
}
public static String generateSignedSAMLResponseBase64(
String issuerEntityId,
String audience,
String acsUrl,
String nameId,
Map<String, List<String>> attributes,
String inResponseTo,
KeyMaterial keyMaterial,
int notOnOrAfterSeconds
) {
Instant now = Instant.now();
Instant notOnOrAfter = now.plusSeconds(Math.max(60, notOnOrAfterSeconds));
Response response = build(Response.DEFAULT_ELEMENT_NAME);
response.setID(randomId());
response.setVersion(SAMLVersion.VERSION_20);
response.setIssueInstant(now);
response.setDestination(acsUrl);
if (inResponseTo != null) {
response.setInResponseTo(inResponseTo);
}
Issuer issuer = build(Issuer.DEFAULT_ELEMENT_NAME);
issuer.setValue(issuerEntityId);
response.setIssuer(issuer);
Status status = build(Status.DEFAULT_ELEMENT_NAME);
StatusCode statusCode = build(StatusCode.DEFAULT_ELEMENT_NAME);
statusCode.setValue(StatusCode.SUCCESS);
status.setStatusCode(statusCode);
response.setStatus(status);
Assertion assertion = build(Assertion.DEFAULT_ELEMENT_NAME);
assertion.setID(randomId());
assertion.setIssueInstant(now);
assertion.setVersion(SAMLVersion.VERSION_20);
Issuer assertionIssuer = build(Issuer.DEFAULT_ELEMENT_NAME);
assertionIssuer.setValue(issuerEntityId);
assertion.setIssuer(assertionIssuer);
Subject subject = build(Subject.DEFAULT_ELEMENT_NAME);
NameID nameIdObj = build(NameID.DEFAULT_ELEMENT_NAME);
nameIdObj.setValue(nameId);
nameIdObj.setFormat(NameIDType.PERSISTENT);
subject.setNameID(nameIdObj);
SubjectConfirmation sc = build(SubjectConfirmation.DEFAULT_ELEMENT_NAME);
sc.setMethod(SubjectConfirmation.METHOD_BEARER);
SubjectConfirmationData scd = build(SubjectConfirmationData.DEFAULT_ELEMENT_NAME);
scd.setRecipient(acsUrl);
scd.setNotOnOrAfter(notOnOrAfter);
if (inResponseTo != null) {
scd.setInResponseTo(inResponseTo);
}
sc.setSubjectConfirmationData(scd);
subject.getSubjectConfirmations().add(sc);
assertion.setSubject(subject);
Conditions conditions = build(Conditions.DEFAULT_ELEMENT_NAME);
conditions.setNotBefore(now.minusSeconds(1));
conditions.setNotOnOrAfter(notOnOrAfter);
AudienceRestriction ar = build(AudienceRestriction.DEFAULT_ELEMENT_NAME);
Audience aud = build(Audience.DEFAULT_ELEMENT_NAME);
aud.setURI(audience);
ar.getAudiences().add(aud);
conditions.getAudienceRestrictions().add(ar);
assertion.setConditions(conditions);
AuthnStatement authnStatement = build(AuthnStatement.DEFAULT_ELEMENT_NAME);
authnStatement.setAuthnInstant(now);
AuthnContext authnContext = build(AuthnContext.DEFAULT_ELEMENT_NAME);
AuthnContextClassRef classRef = build(AuthnContextClassRef.DEFAULT_ELEMENT_NAME);
classRef.setURI(AuthnContext.PASSWORD_AUTHN_CTX);
authnContext.setAuthnContextClassRef(classRef);
authnStatement.setAuthnContext(authnContext);
assertion.getAuthnStatements().add(authnStatement);
if (attributes != null && !attributes.isEmpty()) {
AttributeStatement attrStatement = build(AttributeStatement.DEFAULT_ELEMENT_NAME);
for (Map.Entry<String, List<String>> e : attributes.entrySet()) {
Attribute attr = build(Attribute.DEFAULT_ELEMENT_NAME);
attr.setName(e.getKey());
for (String v : e.getValue()) {
XMLObject val = build(new QName(SAMLConstants.SAML20_NS, "AttributeValue", SAMLConstants.SAML20_PREFIX));
// Represent as simple string text node
val.getDOM();
// Fallback: use anyType with text via builder marshaling
// Instead, we can use XSString builder:
org.opensaml.core.xml.schema.impl.XSStringBuilder sb = new org.opensaml.core.xml.schema.impl.XSStringBuilder();
org.opensaml.core.xml.schema.XSString xs = sb.buildObject(
new QName(SAMLConstants.SAML20_NS, "AttributeValue", SAMLConstants.SAML20_PREFIX),
org.opensaml.core.xml.schema.XSString.TYPE_NAME);
xs.setValue(v);
attr.getAttributeValues().add(xs);
}
attrStatement.getAttributes().add(attr);
}
assertion.getAttributeStatements().add(attrStatement);
}
signAssertion(assertion, keyMaterial);
response.getAssertions().add(assertion);
String xml = toXmlString(response);
return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8));
}
public static KeyInfo buildKeyInfoWithCert(X509Certificate cert) {
KeyInfoBuilder keyInfoBuilder = new KeyInfoBuilder();
KeyInfo keyInfo = keyInfoBuilder.buildObject();
X509DataBuilder x509DataBuilder = new X509DataBuilder();
X509Data x509Data = x509DataBuilder.buildObject();
org.opensaml.xmlsec.signature.X509Certificate x509CertElem =
(org.opensaml.xmlsec.signature.X509Certificate) XMLObjectSupport.buildXMLObject(
org.opensaml.xmlsec.signature.X509Certificate.DEFAULT_ELEMENT_NAME);
try {
x509CertElem.setValue(Base64.getEncoder().encodeToString(cert.getEncoded()));
} catch (CertificateEncodingException e) {
throw new RuntimeException(e);
}
x509Data.getX509Certificates().add(x509CertElem);
keyInfo.getX509Datas().add(x509Data);
return keyInfo;
}
private static <T> T build(QName qName) {
return (T) Objects.requireNonNull(
XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(qName)).buildObject(qName);
}
private static String toXmlString(XMLObject xmlObject) {
try {
Element el = XMLObjectSupport.marshall(xmlObject);
return SerializeSupport.nodeToString(el);
} catch (MarshallingException e) {
throw new RuntimeException(e);
}
}
private static void signAssertion(Assertion assertion, KeyMaterial km) {
try {
Credential cred = CredentialSupport.getSimpleCredential(km.certificate, km.privateKey);
SignatureBuilder signatureBuilder = new SignatureBuilder();
Signature signature = signatureBuilder.buildObject();
signature.setSigningCredential(cred);
signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
signature.setKeyInfo(buildKeyInfoWithCert(km.certificate));
assertion.setSignature(signature);
XMLObjectSupport.marshall(assertion);
Signer.signObject(signature);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static String randomId() {
return "_" + new BigInteger(160, new SecureRandom()).toString(16);
}
public static X509Certificate parseCertificateFromPEM(String pem) {
try {
String base64 = pem.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replaceAll("\n|\r", "").trim();
byte[] der = Base64.getDecoder().decode(base64);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509Certificate) cf.generateCertificate(new java.io.ByteArrayInputStream(der));
} catch (CertificateException e) {
throw new RuntimeException(e);
}
}
public static PrivateKey parsePrivateKeyFromPEM(String pem) {
try {
String base64 = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("[\\n\\r\\s]", "");
byte[] pkcs8 = Base64.getDecoder().decode(base64);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(pkcs8);
return KeyFactory.getInstance("RSA").generatePrivate(spec);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,93 @@
package io.supertokens.test.saml;
import java.nio.charset.StandardCharsets;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.utils.SemVer;
public class SAMLTestUtils {
public static class CreatedClientInfo {
public final String clientId;
public final MockSAML.KeyMaterial keyMaterial;
public final String defaultRedirectURI;
public final String acsURL;
public final String idpEntityId;
public final String idpSsoUrl;
public CreatedClientInfo(String clientId, MockSAML.KeyMaterial keyMaterial,
String defaultRedirectURI, String acsURL, String idpEntityId, String idpSsoUrl) {
this.clientId = clientId;
this.keyMaterial = keyMaterial;
this.defaultRedirectURI = defaultRedirectURI;
this.acsURL = acsURL;
this.idpEntityId = idpEntityId;
this.idpSsoUrl = idpSsoUrl;
}
}
public static CreatedClientInfo createClientWithGeneratedMetadata(TestingProcessManager.TestingProcess process,
String defaultRedirectURI,
String acsURL,
String idpEntityId,
String idpSsoUrl) throws Exception {
return createClientWithGeneratedMetadata(process, defaultRedirectURI, acsURL, idpEntityId, idpSsoUrl, false);
}
public static CreatedClientInfo createClientWithGeneratedMetadata(TestingProcessManager.TestingProcess process,
String defaultRedirectURI,
String acsURL,
String idpEntityId,
String idpSsoUrl,
boolean allowIDPInitiatedLogin) throws Exception {
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(StandardCharsets.UTF_8));
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("clientSecret", "secret");
createClientInput.addProperty("defaultRedirectURI", defaultRedirectURI);
JsonArray redirectURIs = new JsonArray();
redirectURIs.add(defaultRedirectURI);
createClientInput.add("redirectURIs", redirectURIs);
createClientInput.addProperty("metadataXML", metadataXMLBase64);
createClientInput.addProperty("allowIDPInitiatedLogin", allowIDPInitiatedLogin);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null, SemVer.v5_4.get(), "saml");
String clientId = createResp.get("clientId").getAsString();
return new CreatedClientInfo(clientId, keyMaterial, defaultRedirectURI, acsURL, idpEntityId, idpSsoUrl);
}
public static String createLoginRequestAndGetRelayState(TestingProcessManager.TestingProcess process,
String clientId,
String redirectURI,
String acsURL,
String state) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("clientId", clientId);
body.addProperty("redirectURI", redirectURI);
body.addProperty("acsURL", acsURL);
if (state != null) {
body.addProperty("state", state);
}
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
String ssoRedirectURI = resp.get("ssoRedirectURI").getAsString();
int idx = ssoRedirectURI.indexOf("RelayState=");
if (idx == -1) {
throw new IllegalStateException("RelayState not found in ssoRedirectURI");
}
String relayStatePart = ssoRedirectURI.substring(idx + "RelayState=".length());
int amp = relayStatePart.indexOf('&');
String relayState = amp == -1 ? relayStatePart : relayStatePart.substring(0, amp);
return java.net.URLDecoder.decode(relayState, java.nio.charset.StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,481 @@
/*
* Copyright (c) 2025, VRAI Labs and/or its affiliates. All rights reserved.
*
* This software is licensed under the Apache License, Version 2.0 (the
* "License") as published by the Apache Software Foundation.
*
* You may not use this file except in compliance with the License. You may
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package io.supertokens.test.saml.api;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import io.supertokens.pluginInterface.STORAGE_TYPE;
import io.supertokens.storageLayer.StorageLayer;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.httpRequest.HttpResponseException;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.utils.SemVer;
public class CreateOrUpdateSAMLClientTest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Test
public void testCreationWithClientSecret() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String generatedMetadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(generatedMetadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXMLBase64);
String clientSecret = "my-secret-abc-123";
createClientInput.addProperty("clientSecret", clientSecret);
JsonObject resp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
// Ensure structure contains clientSecret and matches provided value
assertEquals("OK", resp.get("status").getAsString());
assertTrue(resp.has("clientSecret"));
assertEquals(clientSecret, resp.get("clientSecret").getAsString());
assertTrue(resp.get("clientId").getAsString().startsWith("st_saml_"));
assertEquals("http://localhost:3000/auth/callback/saml-mock", resp.get("defaultRedirectURI").getAsString());
assertTrue(resp.get("redirectURIs").isJsonArray());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testCreationWithPredefinedClientId() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject createClientInput = new JsonObject();
String customClientId = "st_saml_custom_12345";
createClientInput.addProperty("clientId", customClientId);
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial km = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, km.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXMLBase64);
JsonObject resp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
// Ensure custom clientId is respected and standard fields present
verifyClientStructureWithoutClientSecret(resp, false);
assertEquals("OK", resp.get("status").getAsString());
assertEquals(customClientId, resp.get("clientId").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
return;
}
JsonObject createClientInput = new JsonObject();
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'defaultRedirectURI' is invalid in JSON input", e.getMessage());
}
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-azure");
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'redirectURIs' is invalid in JSON input", e.getMessage());
}
createClientInput.add("redirectURIs", new JsonArray());
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: redirectURIs is required in the input", e.getMessage());
}
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-azure");
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'metadataXML' is invalid in JSON input", e.getMessage());
}
createClientInput.addProperty("metadataXML", "");
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: metadataXML does not have a valid SAML metadata", e.getMessage());
}
String helloXml = "<hello>world</hello>";
String helloXmlBase64 = java.util.Base64.getEncoder().encodeToString(helloXml.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", helloXmlBase64);
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: metadataXML does not have a valid SAML metadata", e.getMessage());
}
// has an invalid certificate
String metadataXML = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
"<md:EntityDescriptor xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\" entityID=\"https://saml.example.com/entityid\" validUntil=\"2035-10-13T09:51:02.835Z\">\n" +
" <md:IDPSSODescriptor WantAuthnRequestsSigned=\"true\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\">\n" +
" <md:KeyDescriptor use=\"signing\">\n" +
" <ds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">\n" +
" <ds:X509Data>\n" +
" <ds:X509Certificate>MIIC4jCCAcoCCQC33wnybT5QZDANBgkqhkiG9w0BAQsFADAyMQswCQYDVQQGEwJV\n" +
"SzEPMA0GA1UECgwGQm94eUhRMRIwEAYDVQQDDAlNb2NrIFNBTUwwIBcNMjIwMjI4\n" +
"MjE0NjM4WhgPMzAyMTA3MDEyMTQ2MzhaMDIxCzAJBgNVBAYTAlVLMQ8wDQYDVQQK\n" +
"DAZCb3h5SFExEjAQBgNVBAMMCU1vY2sgU0FNTDCCASIwDQYJKoZIhvcNAQEBBQAD\n" +
"ggEPADCCAQoCggEBALGfYettMsct1T6tVUwTudNJH5Pnb9GGnkXi9Zw/e6x45DD0\n" +
"RuRONbFlJ2T4RjAE/uG+AjXxXQ8o2SZfb9+GgmCHuTJFNgHoZ1nFVXCmb/Hg8Hpd\n" +
"4vOAGXndixaReOiq3EH5XvpMjMkJ3+8+9VYMzMZOjkgQtAqO36eAFFfNKX7dTj3V\n" +
"2/W5sGHRv+9AarggJkF+ptUkXoLtVA51wcfYm6hILptpde5FQC8RWY1YrswBWAEZ\n" +
"NfyrR4JeSweElNHg4NVOs4TwGjOPwWGqzTfgTlECAwEAATANBgkqhkiG9w0BAQsF\n" +
"AAOCAQEAAYRlYflSXAWoZpFfwNiCQVE5d9zZ0DPzNdWhAybXcTyMf0z5mDf6FWBW\n" +
"5Gyoi9u3EMEDnzLcJNkwJAAc39Apa4I2/tml+Jy29dk8bTyX6m93ngmCgdLh5Za4\n" +
"khuU3AM3L63g7VexCuO7kwkjh/+LqdcIXsVGO6XDfu2QOs1Xpe9zIzLpwm/RNYeX\n" +
"UjbSj5ce/jekpAw7qyVVL4xOyh8AtUW1ek3wIw1MJvEgEPt0d16oshWJpoS1OT8L\n" +
"r/22SvYEo3EmSGdTVGgk3x3s+A0qWAqTcyjr7Q4s/GKYRFfomGwz0TZ4Iw1ZN99M\n" +
"m0eo2USlSRTVl7QHRTuiuSThHpLKQQ==</ds:X509Certificate>\n" +
" </ds:X509Data>\n" +
" </ds:KeyInfo>\n" +
" </md:KeyDescriptor>\n" +
" <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>\n" +
" <md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://mocksaml.com/api/saml/sso\"/>\n" +
" <md:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"https://mocksaml.com/api/saml/sso\"/>\n" +
" </md:IDPSSODescriptor>\n" +
"</md:EntityDescriptor>";
metadataXML = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXML);
try {
HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: metadataXML does not have a valid SAML metadata", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testCreationUsingXML() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial km = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, km.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXMLBase64);
JsonObject resp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
verifyClientStructureWithoutClientSecret(resp, true);
assertEquals("OK", resp.get("status").getAsString());
// Check the actual returned values for each field
assertTrue(resp.get("clientId").getAsString().startsWith("st_saml_"));
assertEquals("http://localhost:3000/auth/callback/saml-mock", resp.get("defaultRedirectURI").getAsString());
assertTrue(resp.get("redirectURIs").isJsonArray());
assertEquals(1, resp.get("redirectURIs").getAsJsonArray().size());
assertEquals("http://localhost:3000/auth/callback/saml-mock", resp.get("redirectURIs").getAsJsonArray().get(0).getAsString());
assertEquals(idpEntityId, resp.get("idpEntityId").getAsString());
String expectedCertBase64 = java.util.Base64.getEncoder().encodeToString(km.certificate.getEncoded());
assertEquals(expectedCertBase64, resp.get("idpSigningCertificate").getAsString());
assertFalse(resp.get("allowIDPInitiatedLogin").getAsBoolean());
assertEquals("OK", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testUpdateClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create a client first
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial km2 = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId2 = "https://saml.example.com/entityid";
String idpSsoUrl2 = "https://mocksaml.com/api/saml/sso";
String metadataXML2 = MockSAML.generateIdpMetadataXML(idpEntityId2, idpSsoUrl2, km2.certificate);
String metadataXMLBase64_2 = java.util.Base64.getEncoder().encodeToString(metadataXML2.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXMLBase64_2);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
verifyClientStructureWithoutClientSecret(createResp, true);
String clientId = createResp.get("clientId").getAsString();
// Update fields
JsonObject updateInput = new JsonObject();
updateInput.addProperty("clientId", clientId);
updateInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock-2");
JsonArray updatedRedirectURIs = new JsonArray();
updatedRedirectURIs.add("http://localhost:3000/auth/callback/saml-mock-2");
updatedRedirectURIs.add("http://localhost:3000/auth/callback/saml-mock-3");
updateInput.add("redirectURIs", updatedRedirectURIs);
updateInput.addProperty("allowIDPInitiatedLogin", true);
// metadata is required by the API even on update
updateInput.addProperty("metadataXML", metadataXMLBase64_2);
JsonObject updateResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", updateInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
verifyClientStructureWithoutClientSecret(updateResp, false);
assertEquals("OK", updateResp.get("status").getAsString());
assertEquals(clientId, updateResp.get("clientId").getAsString());
assertEquals("http://localhost:3000/auth/callback/saml-mock-2", updateResp.get("defaultRedirectURI").getAsString());
assertTrue(updateResp.get("redirectURIs").isJsonArray());
assertEquals(2, updateResp.get("redirectURIs").getAsJsonArray().size());
assertEquals("http://localhost:3000/auth/callback/saml-mock-2", updateResp.get("redirectURIs").getAsJsonArray().get(0).getAsString());
assertEquals("http://localhost:3000/auth/callback/saml-mock-3", updateResp.get("redirectURIs").getAsJsonArray().get(1).getAsString());
assertTrue(updateResp.get("allowIDPInitiatedLogin").getAsBoolean());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
private static void verifyClientStructureWithoutClientSecret(JsonObject client, boolean generatedClientId) throws Exception {
assertEquals(8, client.size());
String[] FIELDS = new String[]{
"clientId",
"defaultRedirectURI",
"redirectURIs",
"idpEntityId",
"idpSigningCertificate",
"allowIDPInitiatedLogin",
"enableRequestSigning",
"status"
};
for (String field : FIELDS) {
assertTrue(client.has(field));
}
if (generatedClientId) {
assertTrue(client.get("clientId").getAsString().startsWith("st_saml_"));
}
assertTrue(client.get("defaultRedirectURI").isJsonPrimitive());
assertTrue(client.get("redirectURIs").isJsonArray());
assertTrue(client.get("redirectURIs").getAsJsonArray().size() > 0);
assertTrue(client.get("idpEntityId").isJsonPrimitive());
assertTrue(client.get("idpSigningCertificate").isJsonPrimitive());
assertTrue(client.get("enableRequestSigning").isJsonPrimitive());
assertEquals("OK", client.get("status").getAsString());
}
@Test
public void testDuplicateEntityId() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
return;
}
// Create first client
JsonObject input1 = new JsonObject();
input1.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
input1.add("redirectURIs", new JsonArray());
input1.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
MockSAML.KeyMaterial km1 = MockSAML.generateSelfSignedKeyMaterial();
String duplicateEntityId = "https://saml.example.com/entityid-dup";
String ssoUrl = "https://mocksaml.com/api/saml/sso";
String metadata1 = MockSAML.generateIdpMetadataXML(duplicateEntityId, ssoUrl, km1.certificate);
String metadata1B64 = java.util.Base64.getEncoder().encodeToString(metadata1.getBytes(java.nio.charset.StandardCharsets.UTF_8));
input1.addProperty("metadataXML", metadata1B64);
JsonObject createResp1 = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", input1, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", createResp1.get("status").getAsString());
// Attempt to create second client with the same IdP entity ID
JsonObject input2 = new JsonObject();
input2.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
input2.add("redirectURIs", new JsonArray());
input2.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
MockSAML.KeyMaterial km2 = MockSAML.generateSelfSignedKeyMaterial();
String metadata2 = MockSAML.generateIdpMetadataXML(duplicateEntityId, ssoUrl, km2.certificate);
String metadata2B64 = java.util.Base64.getEncoder().encodeToString(metadata2.getBytes(java.nio.charset.StandardCharsets.UTF_8));
input2.addProperty("metadataXML", metadata2B64);
JsonObject createResp2 = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", input2, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("DUPLICATE_IDP_ENTITY_ERROR", createResp2.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
}

View File

@ -0,0 +1,222 @@
package io.supertokens.test.saml.api;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.utils.SemVer;
public class CreateSamlLoginRedirectAPITest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// missing clientId
{
JsonObject body = new JsonObject();
body.addProperty("redirectURI", "http://localhost:3000/auth/callback/saml-mock");
body.addProperty("acsURL", "http://localhost:3000/acs");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'clientId' is invalid in JSON input", e.getMessage());
}
}
// missing redirectURI
{
JsonObject body = new JsonObject();
body.addProperty("clientId", "some-client");
body.addProperty("acsURL", "http://localhost:3000/acs");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'redirectURI' is invalid in JSON input", e.getMessage());
}
}
// missing acsURL
{
JsonObject body = new JsonObject();
body.addProperty("clientId", "some-client");
body.addProperty("redirectURI", "http://localhost:3000/auth/callback/saml-mock");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'acsURL' is invalid in JSON input", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testInvalidClientId() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject body = new JsonObject();
body.addProperty("clientId", "non-existent-client");
body.addProperty("redirectURI", "http://localhost:3000/auth/callback/saml-mock");
body.addProperty("acsURL", "http://localhost:3000/acs");
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_CLIENT_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testInvalidRedirectURI() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("spEntityId", "http://example.com/saml");
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
createClientInput.addProperty("metadataXML", metadataXMLBase64);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", createResp.get("status").getAsString());
String clientId = createResp.get("clientId").getAsString();
JsonObject body = new JsonObject();
body.addProperty("clientId", clientId);
body.addProperty("redirectURI", "http://localhost:3000/another/callback");
body.addProperty("acsURL", "http://localhost:3000/acs");
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_CLIENT_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testValidLoginRedirect() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Prepare IdP metadata using MockSAML self-signed certificate
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
java.security.cert.X509Certificate cert = keyMaterial.certificate;
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, cert);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
// Create client using metadataXML
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("spEntityId", "http://example.com/saml");
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
createClientInput.addProperty("metadataXML", metadataXMLBase64);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", createResp.get("status").getAsString());
String clientId = createResp.get("clientId").getAsString();
// Create login request with valid redirect URI
JsonObject body = new JsonObject();
body.addProperty("clientId", clientId);
body.addProperty("redirectURI", "http://localhost:3000/auth/callback/saml-mock");
body.addProperty("acsURL", "http://localhost:3000/acs");
body.addProperty("state", "abc123");
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/login", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
// Verify response structure
assertEquals("OK", resp.get("status").getAsString());
assertTrue(resp.has("ssoRedirectURI"));
String ssoRedirectURI = resp.get("ssoRedirectURI").getAsString();
assertTrue(ssoRedirectURI.startsWith(idpSsoUrl + "?"));
assertTrue(ssoRedirectURI.contains("SAMLRequest="));
assertTrue(ssoRedirectURI.contains("RelayState="));
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
}

View File

@ -0,0 +1,293 @@
package io.supertokens.test.saml.api;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.test.saml.SAMLTestUtils;
import io.supertokens.utils.SemVer;
public class GetUserinfoTest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Missing accessToken
{
JsonObject body = new JsonObject();
body.addProperty("clientId", "some-client");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/user", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'accessToken' is invalid in JSON input", e.getMessage());
}
}
// Missing clientId
{
JsonObject body = new JsonObject();
body.addProperty("accessToken", "some-access-token");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/user", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'clientId' is invalid in JSON input", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testInvalidAccessToken() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Test with invalid/fake access token
{
JsonObject body = new JsonObject();
body.addProperty("accessToken", "invalid-access-token-12345");
body.addProperty("clientId", "test-client-id");
JsonObject response = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/user", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_TOKEN_ERROR", response.get("status").getAsString());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testValidTokenWithWrongClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create first client
String spEntityId1 = "http://example.com/saml";
String defaultRedirectURI1 = "http://localhost:3000/auth/callback/saml-mock";
String acsURL1 = "http://localhost:3000/acs";
String idpEntityId1 = "https://saml.example.com/entityid";
String idpSsoUrl1 = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo1 = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI1,
acsURL1,
idpEntityId1,
idpSsoUrl1
);
// Create second client
String spEntityId2 = "http://example2.com/saml";
String defaultRedirectURI2 = "http://localhost:3001/auth/callback/saml-mock";
String acsURL2 = "http://localhost:3001/acs";
String idpEntityId2 = "https://saml2.example.com/entityid";
String idpSsoUrl2 = "https://mocksaml2.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo2 = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI2,
acsURL2,
idpEntityId2,
idpSsoUrl2
);
// Create a login request for client1 to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo1.clientId,
clientInfo1.defaultRedirectURI,
clientInfo1.acsURL,
"test-state"
);
// Generate a valid SAML Response for client1
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo1.idpEntityId,
"https://saml.supertokens.com",
clientInfo1.acsURL,
"user@example.com",
null,
relayState,
clientInfo1.keyMaterial,
300
);
// Process the callback for client1 to get a valid access token
JsonObject callbackBody = new JsonObject();
callbackBody.addProperty("samlResponse", samlResponseBase64);
callbackBody.addProperty("relayState", relayState);
JsonObject callbackResp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", callbackBody, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", callbackResp.get("status").getAsString());
// Extract the access token from the redirect URI
String redirectURI = callbackResp.get("redirectURI").getAsString();
String accessToken = extractAccessTokenFromRedirectURI(redirectURI);
// Now try to use the valid access token from client1 with client2's clientId
JsonObject userInfoBody = new JsonObject();
userInfoBody.addProperty("accessToken", accessToken);
userInfoBody.addProperty("clientId", clientInfo2.clientId); // Wrong client ID
JsonObject userInfoResp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/user", userInfoBody, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_TOKEN_ERROR", userInfoResp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testValidTokenWithCorrectClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
// Generate a valid SAML Response
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
// Process the callback to get a valid access token
JsonObject callbackBody = new JsonObject();
callbackBody.addProperty("samlResponse", samlResponseBase64);
callbackBody.addProperty("relayState", relayState);
JsonObject callbackResp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", callbackBody, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", callbackResp.get("status").getAsString());
// Extract the access token from the redirect URI
String redirectURI = callbackResp.get("redirectURI").getAsString();
String accessToken = extractAccessTokenFromRedirectURI(redirectURI);
// Use the valid access token with the correct client ID
JsonObject userInfoBody = new JsonObject();
userInfoBody.addProperty("accessToken", accessToken);
userInfoBody.addProperty("clientId", clientInfo.clientId);
JsonObject userInfoResp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/user", userInfoBody, 1000, 1000, null, SemVer.v5_4.get(), "saml");
// Verify successful response
assertEquals("OK", userInfoResp.get("status").getAsString());
assertNotNull(userInfoResp.get("sub"));
assertEquals("user@example.com", userInfoResp.get("sub").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
private String extractAccessTokenFromRedirectURI(String redirectURI) {
// Extract the 'code' parameter from the redirect URI
// Format: http://localhost:3000/auth/callback/saml-mock?code=some-uuid&state=test-state
int codeIndex = redirectURI.indexOf("code=");
if (codeIndex == -1) {
throw new IllegalStateException("Code parameter not found in redirect URI: " + redirectURI);
}
String codePart = redirectURI.substring(codeIndex + "code=".length());
int ampIndex = codePart.indexOf('&');
if (ampIndex != -1) {
codePart = codePart.substring(0, ampIndex);
}
return java.net.URLDecoder.decode(codePart, java.nio.charset.StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,455 @@
package io.supertokens.test.saml.api;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.test.saml.SAMLTestUtils;
import io.supertokens.utils.SemVer;
public class HandleSAMLCallbackTest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Missing SAMLResponse
{
JsonObject body = new JsonObject();
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'samlResponse' is invalid in JSON input", e.getMessage());
}
}
// Empty SAMLResponse (base64 of empty string is empty)
{
JsonObject body = new JsonObject();
body.addProperty("samlResponse", "");
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Invalid or malformed SAML response input", e.getMessage());
}
}
// Non-XML SAMLResponse (base64 of 'hello')
{
String nonXmlBase64 = java.util.Base64.getEncoder().encodeToString("hello".getBytes(java.nio.charset.StandardCharsets.UTF_8));
JsonObject body = new JsonObject();
body.addProperty("samlResponse", nonXmlBase64);
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Invalid or malformed SAML response input", e.getMessage());
}
}
// Arbitrary XML as SAMLResponse (not a SAML Response element)
{
String xml = "<root></root>";
String xmlBase64 = java.util.Base64.getEncoder().encodeToString(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8));
JsonObject body = new JsonObject();
body.addProperty("samlResponse", xmlBase64);
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Invalid or malformed SAML response input", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testNonExistingRelayState() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
null,
clientInfo.keyMaterial,
300
);
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
body.addProperty("relayState", "this-does-not-exist");
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_RELAY_STATE_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testWrongAudienceInSAMLResponse() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Audience that does not match the client's SP Entity ID
String wrongAudience = "http://wrong.example.com/sp";
// Create a login request to generate a RelayState, then use it during callback
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
wrongAudience,
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
body.addProperty("relayState", relayState);
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("SAML_RESPONSE_VERIFICATION_FAILED_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testWrongSignatureInSAMLResponse() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState, then use it during callback
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
// Generate a different key material to sign the assertion with the wrong certificate
MockSAML.KeyMaterial wrongKeyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
wrongKeyMaterial,
300
);
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
body.addProperty("relayState", relayState);
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("SAML_RESPONSE_VERIFICATION_FAILED_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testClientDeletedBeforeProcessingCallbackResultsInInvalidClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
// Create a valid SAML Response for this client and the relayState
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
// Now delete the client before processing the callback
JsonObject removeBody = new JsonObject();
removeBody.addProperty("clientId", clientInfo.clientId);
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", removeBody, 1000, 1000, null, SemVer.v5_4.get(), "saml");
// Process the callback; should result in INVALID_CLIENT_ERROR
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
body.addProperty("relayState", relayState);
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_CLIENT_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testIDPFlowWithIDPDisallowedOnClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
// Create a client with allowIDPInitiatedLogin = false (default)
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl,
false // allowIDPInitiatedLogin = false
);
// Generate an IDP-initiated SAML response (no RelayState, no InResponseTo)
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
null, // no inResponseTo for IDP-initiated
clientInfo.keyMaterial,
300
);
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
// Intentionally omit relayState to simulate IDP-initiated login
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("IDP_LOGIN_DISALLOWED_ERROR", resp.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testIDPFlow() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
// Create a client with allowIDPInitiatedLogin = true
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl,
true // allowIDPInitiatedLogin = true
);
// Generate an IDP-initiated SAML response (no RelayState, no InResponseTo)
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
null, // no inResponseTo for IDP-initiated
clientInfo.keyMaterial,
300
);
JsonObject body = new JsonObject();
body.addProperty("samlResponse", samlResponseBase64);
// Intentionally omit relayState to simulate IDP-initiated login
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/callback", body, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", resp.get("status").getAsString());
String redirectURI = resp.get("redirectURI").getAsString();
// Check that the redirectURI contains the code query parameter
assertNotNull(redirectURI);
assertTrue("Redirect URI should contain code parameter", redirectURI.contains("code="));
// Check it starts with the default redirect URI
assertTrue("Redirect URI should start with default redirect URI", redirectURI.startsWith(defaultRedirectURI));
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
}

View File

@ -0,0 +1,733 @@
package io.supertokens.test.saml.api;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.httpRequest.HttpResponseException;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.test.saml.SAMLTestUtils;
import io.supertokens.utils.SemVer;
public class LegacyTest5_4 {
private static final String TEST_REDIRECT_URI = "http://localhost:3000/auth/callback/saml-mock";
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() throws IOException {
Utils.reset();
// Set the legacy ACS URL for testing
Utils.setValueInConfig("saml_legacy_acs_url", "http://localhost:3567/recipe/saml/legacy/callback");
}
// ========== LegacyAuthorizeAPI Tests ==========
@Test
public void testLegacyAuthorizeBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Missing client_id
{
Map<String, String> params = new HashMap<>();
params.put("redirect_uri", TEST_REDIRECT_URI);
params.put("state", "test-state");
try {
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/authorize", params, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'client_id' is missing in GET request", e.getMessage());
}
}
// Missing redirect_uri
{
Map<String, String> params = new HashMap<>();
params.put("client_id", "test-client");
params.put("state", "test-state");
try {
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/authorize", params, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'redirect_uri' is missing in GET request", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyAuthorizeInvalidClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Test with non-existent client_id
Map<String, String> params = new HashMap<>();
params.put("client_id", "non-existent-client");
params.put("redirect_uri", TEST_REDIRECT_URI);
params.put("state", "test-state");
JsonObject response = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/authorize", params, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("INVALID_CLIENT_ERROR", response.get("status").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyAuthorizeValidClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Test valid authorization request
String redirectURI = TEST_REDIRECT_URI; // Use the same redirect URI as configured in the client
String state = "test-state-123";
// Create query parameters map
Map<String, String> params = new HashMap<>();
params.put("client_id", clientInfo.clientId);
params.put("redirect_uri", redirectURI);
params.put("state", state);
// This should redirect to SSO URL, so we expect a 307 redirect
try {
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/authorize", params, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail("Expected redirect response");
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(307, e.statusCode);
// Verify the redirect URL contains expected parameters
String location = e.getMessage();
assertNotNull(location);
assertNotNull("Location header should contain SSO URL", location);
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
// ========== LegacyCallbackAPI Tests ==========
@Test
public void testLegacyCallbackBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Missing SAMLResponse
{
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", new JsonObject(), 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Missing form field: SAMLResponse", e.getMessage());
}
}
// Empty SAMLResponse
{
JsonObject formData = new JsonObject();
formData.addProperty("SAMLResponse", "");
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Missing form field: SAMLResponse", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyCallbackInvalidRelayState() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
null,
clientInfo.keyMaterial,
300
);
JsonObject formData = new JsonObject();
formData.addProperty("SAMLResponse", samlResponseBase64);
formData.addProperty("RelayState", "invalid-relay-state");
try {
String response = HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: INVALID_RELAY_STATE_ERROR", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyCallbackValidResponse() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
JsonObject formData = new JsonObject();
formData.addProperty("SAMLResponse", samlResponseBase64);
formData.addProperty("RelayState", relayState);
// This should redirect to the callback URL with authorization code
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml", false);
fail("Expected redirect response");
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(302, e.statusCode);
String location = e.getMessage();
assertNotNull(location);
assertNotNull("Location header should contain redirect URI", location);
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
// ========== LegacyTokenAPI Tests ==========
@Test
public void testLegacyTokenBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Missing client_id
{
JsonObject formData = new JsonObject();
formData.addProperty("client_secret", clientInfo.clientId); // In legacy API, client_secret is same as client_id
formData.addProperty("code", "test-code");
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Missing form field: client_id", e.getMessage());
}
}
// Missing client_secret
{
JsonObject formData = new JsonObject();
formData.addProperty("client_id", clientInfo.clientId);
formData.addProperty("code", "test-code");
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Missing form field: client_secret", e.getMessage());
}
}
// Missing code
{
JsonObject formData = new JsonObject();
formData.addProperty("client_id", clientInfo.clientId);
formData.addProperty("client_secret", clientInfo.clientId); // In legacy API, client_secret is same as client_id
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Missing form field: code", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyTokenInvalidClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject formData = new JsonObject();
formData.addProperty("client_id", "non-existent-client");
formData.addProperty("client_secret", "test-secret");
formData.addProperty("code", "test-code");
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Invalid client_id", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyTokenInvalidSecret() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
JsonObject formData = new JsonObject();
formData.addProperty("client_id", clientInfo.clientId);
formData.addProperty("client_secret", "wrong-secret");
formData.addProperty("code", "test-code");
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", formData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Invalid client_secret", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyTokenValidRequest() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
// Process callback to get authorization code
JsonObject callbackFormData = new JsonObject();
callbackFormData.addProperty("SAMLResponse", samlResponseBase64);
callbackFormData.addProperty("RelayState", relayState);
String redirectURI = null;
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", callbackFormData, 1000, 1000, null, SemVer.v5_4.get(), "saml", false);
fail("Expected redirect response");
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(302, e.statusCode);
redirectURI = e.getMessage();
}
// Extract authorization code from redirect URI
String authCode = extractAuthCodeFromRedirectURI(redirectURI);
// Now test token exchange
JsonObject tokenFormData = new JsonObject();
tokenFormData.addProperty("client_id", clientInfo.clientId);
tokenFormData.addProperty("client_secret", "secret");
tokenFormData.addProperty("code", authCode);
JsonObject tokenResponse = HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", tokenFormData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", tokenResponse.get("status").getAsString());
assertNotNull(tokenResponse.get("access_token"));
String accessToken = tokenResponse.get("access_token").getAsString();
assertEquals(authCode + "." + clientInfo.clientId, accessToken);
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
// ========== LegacyUserinfoAPI Tests ==========
@Test
public void testLegacyUserinfoBadInput() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Missing Authorization header
{
try {
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/userinfo", null, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Authorization header is required", e.getMessage());
}
}
// Invalid Authorization header format
{
try {
HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/userinfo", null, 1000, 1000, null, SemVer.v5_4.get(), "saml");
fail();
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Authorization header is required", e.getMessage());
}
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyUserinfoInvalidToken() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
try {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer invalid-token");
JsonObject response = HttpRequestForTesting.sendGETRequestWithHeaders(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/userinfo", null, headers, 1000, 1000, null, SemVer.v5_4.get(), "saml");
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: INVALID_TOKEN_ERROR", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testLegacyUserinfoValidToken() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Create SAML client
String spEntityId = "http://example.com/saml";
String defaultRedirectURI = "http://localhost:3000/auth/callback/saml-mock";
String acsURL = "http://localhost:3000/acs";
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
SAMLTestUtils.CreatedClientInfo clientInfo = SAMLTestUtils.createClientWithGeneratedMetadata(
process,
defaultRedirectURI,
acsURL,
idpEntityId,
idpSsoUrl
);
// Create a login request to generate a RelayState
String relayState = SAMLTestUtils.createLoginRequestAndGetRelayState(
process,
clientInfo.clientId,
clientInfo.defaultRedirectURI,
clientInfo.acsURL,
"test-state"
);
String samlResponseBase64 = MockSAML.generateSignedSAMLResponseBase64(
clientInfo.idpEntityId,
"https://saml.supertokens.com",
clientInfo.acsURL,
"user@example.com",
null,
relayState,
clientInfo.keyMaterial,
300
);
// Process callback to get authorization code
JsonObject callbackFormData = new JsonObject();
callbackFormData.addProperty("SAMLResponse", samlResponseBase64);
callbackFormData.addProperty("RelayState", relayState);
String redirectURI = null;
try {
HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/callback", callbackFormData, 1000, 1000, null, SemVer.v5_4.get(), "saml", false);
fail("Expected redirect response");
} catch (io.supertokens.test.httpRequest.HttpResponseException e) {
assertEquals(302, e.statusCode);
redirectURI = e.getMessage();
}
// Extract authorization code from redirect URI
String authCode = extractAuthCodeFromRedirectURI(redirectURI);
// Exchange code for access token
JsonObject tokenFormData = new JsonObject();
tokenFormData.addProperty("client_id", clientInfo.clientId);
tokenFormData.addProperty("client_secret", "secret");
tokenFormData.addProperty("code", authCode);
JsonObject tokenResponse = HttpRequestForTesting.sendFormDataPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/token", tokenFormData, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertEquals("OK", tokenResponse.get("status").getAsString());
String accessToken = tokenResponse.get("access_token").getAsString();
// Now test userinfo with valid access token
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
JsonObject userInfoResponse = HttpRequestForTesting.sendGETRequestWithHeaders(process.getProcess(), "",
"http://localhost:3567/recipe/saml/legacy/userinfo", null, headers, 1000, 1000, null, SemVer.v5_4.get(), "saml");
assertNotNull(userInfoResponse.get("id"));
assertEquals("user@example.com", userInfoResponse.get("id").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
// Helper method to extract authorization code from redirect URI
private String extractAuthCodeFromRedirectURI(String redirectURI) {
// Extract the 'code' parameter from the redirect URI
// Format: http://localhost:3000/auth/callback/saml-mock?code=some-uuid&state=test-state
int codeIndex = redirectURI.indexOf("code=");
if (codeIndex == -1) {
throw new IllegalStateException("Code parameter not found in redirect URI: " + redirectURI);
}
String codePart = redirectURI.substring(codeIndex + "code=".length());
int ampIndex = codePart.indexOf('&');
if (ampIndex != -1) {
codePart = codePart.substring(0, ampIndex);
}
return java.net.URLDecoder.decode(codePart, java.nio.charset.StandardCharsets.UTF_8);
}
}

View File

@ -0,0 +1,186 @@
package io.supertokens.test.saml.api;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.utils.SemVer;
public class ListSAMLClientsTest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testEmptyList() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject listResp = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/list", null, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", listResp.get("status").getAsString());
assertTrue(listResp.has("clients"));
assertTrue(listResp.get("clients").isJsonArray());
assertEquals(0, listResp.get("clients").getAsJsonArray().size());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testListAfterCreatingClientViaXML() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("spEntityId", "http://example.com/saml");
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
createClientInput.addProperty("metadataXML", metadataXMLBase64);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", createResp.get("status").getAsString());
String clientId = createResp.get("clientId").getAsString();
JsonObject listResp = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/list", null, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", listResp.get("status").getAsString());
assertTrue(listResp.get("clients").isJsonArray());
JsonArray clients = listResp.get("clients").getAsJsonArray();
assertEquals(1, clients.size());
JsonObject listed = findByClientId(clients, clientId);
assertNotNull(listed);
// should not include clientSecret since we didn't set it
assertFalse(listed.has("clientSecret"));
assertEquals("http://localhost:3000/auth/callback/saml-mock", listed.get("defaultRedirectURI").getAsString());
assertTrue(listed.get("redirectURIs").isJsonArray());
assertEquals(1, listed.get("redirectURIs").getAsJsonArray().size());
assertEquals("http://localhost:3000/auth/callback/saml-mock",
listed.get("redirectURIs").getAsJsonArray().get(0).getAsString());
assertEquals(idpEntityId, listed.get("idpEntityId").getAsString());
assertTrue(listed.has("idpSigningCertificate"));
assertFalse(listed.get("idpSigningCertificate").getAsString().isEmpty());
assertFalse(listed.get("allowIDPInitiatedLogin").getAsBoolean());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testListIncludesClientSecretWhenProvided() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
JsonObject createClientInput = new JsonObject();
createClientInput.addProperty("spEntityId", "http://example.com/saml");
createClientInput.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
createClientInput.add("redirectURIs", new JsonArray());
createClientInput.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
createClientInput.addProperty("metadataXML", metadataXMLBase64);
String clientSecret = "my-secret-xyz";
createClientInput.addProperty("clientSecret", clientSecret);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", createClientInput, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", createResp.get("status").getAsString());
String clientId = createResp.get("clientId").getAsString();
JsonObject listResp = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/list", null, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", listResp.get("status").getAsString());
JsonArray clients = listResp.get("clients").getAsJsonArray();
JsonObject listed = findByClientId(clients, clientId);
assertNotNull(listed);
assertTrue(listed.has("clientSecret"));
assertEquals(clientSecret, listed.get("clientSecret").getAsString());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
private static JsonObject findByClientId(JsonArray clients, String clientId) {
for (JsonElement el : clients) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("clientId") && obj.get("clientId").getAsString().equals(clientId)) {
return obj;
}
}
return null;
}
}

View File

@ -0,0 +1,199 @@
package io.supertokens.test.saml.api;
import io.supertokens.featureflag.EE_FEATURES;
import io.supertokens.featureflag.FeatureFlag;
import io.supertokens.featureflag.FeatureFlagTestContent;
import org.junit.AfterClass;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TestRule;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.supertokens.ProcessState;
import io.supertokens.test.TestingProcessManager;
import io.supertokens.test.Utils;
import io.supertokens.test.httpRequest.HttpRequestForTesting;
import io.supertokens.test.httpRequest.HttpResponseException;
import io.supertokens.test.saml.MockSAML;
import io.supertokens.utils.SemVer;
public class RemoveSAMLClientTest5_4 {
@Rule
public TestRule watchman = Utils.getOnFailure();
@Rule
public TestRule retryFlaky = Utils.retryFlakyTest();
@AfterClass
public static void afterTesting() {
Utils.afterTesting();
}
@Before
public void beforeEach() {
Utils.reset();
}
@Test
public void testDeleteNonExistingClientReturnsFalse() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject body = new JsonObject();
body.addProperty("clientId", "st_saml_does_not_exist");
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", body, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", resp.get("status").getAsString());
assertFalse(resp.get("didExist").getAsBoolean());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testBadInputMissingClientId() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
JsonObject body = new JsonObject();
try {
HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", body, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
// should not reach here
org.junit.Assert.fail();
} catch (HttpResponseException e) {
assertEquals(400, e.statusCode);
assertEquals("Http error. Status Code: 400. Message: Field name 'clientId' is invalid in JSON input", e.getMessage());
}
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testCreateThenDeleteClient() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// create a client first
JsonObject create = new JsonObject();
create.addProperty("spEntityId", "http://example.com/saml");
create.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
create.add("redirectURIs", new JsonArray());
create.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
create.addProperty("metadataXML", metadataXMLBase64);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", create, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
String clientId = createResp.get("clientId").getAsString();
assertTrue(clientId.startsWith("st_saml_"));
// delete it
JsonObject body = new JsonObject();
body.addProperty("clientId", clientId);
JsonObject deleteResp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", body, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", deleteResp.get("status").getAsString());
assertTrue(deleteResp.get("didExist").getAsBoolean());
// verify listing is empty after deletion
JsonObject listResp = HttpRequestForTesting.sendGETRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/list", null, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", listResp.get("status").getAsString());
assertTrue(listResp.get("clients").isJsonArray());
assertEquals(0, listResp.get("clients").getAsJsonArray().size());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
@Test
public void testDeleteTwiceSecondTimeFalse() throws Exception {
String[] args = {"../"};
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
FeatureFlagTestContent.getInstance(process.getProcess())
.setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{
EE_FEATURES.SAML});
// create
JsonObject create = new JsonObject();
create.addProperty("spEntityId", "http://example.com/saml");
create.addProperty("defaultRedirectURI", "http://localhost:3000/auth/callback/saml-mock");
create.add("redirectURIs", new JsonArray());
create.get("redirectURIs").getAsJsonArray().add("http://localhost:3000/auth/callback/saml-mock");
// Generate IdP metadata using MockSAML
MockSAML.KeyMaterial keyMaterial = MockSAML.generateSelfSignedKeyMaterial();
String idpEntityId = "https://saml.example.com/entityid";
String idpSsoUrl = "https://mocksaml.com/api/saml/sso";
String metadataXML = MockSAML.generateIdpMetadataXML(idpEntityId, idpSsoUrl, keyMaterial.certificate);
String metadataXMLBase64 = java.util.Base64.getEncoder().encodeToString(metadataXML.getBytes(java.nio.charset.StandardCharsets.UTF_8));
create.addProperty("metadataXML", metadataXMLBase64);
JsonObject createResp = HttpRequestForTesting.sendJsonPUTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients", create, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
String clientId = createResp.get("clientId").getAsString();
JsonObject body = new JsonObject();
body.addProperty("clientId", clientId);
JsonObject deleteResp1 = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", body, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", deleteResp1.get("status").getAsString());
assertTrue(deleteResp1.get("didExist").getAsBoolean());
JsonObject deleteResp2 = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
"http://localhost:3567/recipe/saml/clients/remove", body, 1000, 1000, null,
SemVer.v5_4.get(), "saml");
assertEquals("OK", deleteResp2.get("status").getAsString());
assertFalse(deleteResp2.get("didExist").getAsBoolean());
process.kill();
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
}
}

View File

@ -31,6 +31,7 @@ import io.supertokens.pluginInterface.multitenancy.AppIdentifier;
import io.supertokens.pluginInterface.multitenancy.TenantIdentifier;
import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage;
import io.supertokens.pluginInterface.oauth.OAuthStorage;
import io.supertokens.pluginInterface.saml.SAMLStorage;
import io.supertokens.pluginInterface.useridmapping.UserIdMappingStorage;
import io.supertokens.pluginInterface.useridmapping.exception.UnknownSuperTokensUserIdException;
import io.supertokens.pluginInterface.useridmapping.exception.UserIdMappingAlreadyExistsException;
@ -809,7 +810,8 @@ public class UserIdMappingTest {
JWTRecipeStorage.class.getName(),
ActiveUsersStorage.class.getName(),
OAuthStorage.class.getName(),
BulkImportStorage.class.getName()
BulkImportStorage.class.getName(),
SAMLStorage.class.getName()
);
Reflections reflections = new Reflections("io.supertokens.pluginInterface");
@ -894,7 +896,8 @@ public class UserIdMappingTest {
JWTRecipeStorage.class.getName(),
ActiveUsersStorage.class.getName(),
OAuthStorage.class.getName(),
BulkImportStorage.class.getName()
BulkImportStorage.class.getName(),
SAMLStorage.class.getName()
);
Reflections reflections = new Reflections("io.supertokens.pluginInterface");
Set<Class<? extends NonAuthRecipeStorage>> classes = reflections.getSubTypesOf(NonAuthRecipeStorage.class);