├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── README.md
├── build.gradle.kts
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── jitpack.yml
├── package-lock.json
├── package.json
├── settings.gradle.kts
├── src
├── main
│ ├── kotlin
│ │ └── de
│ │ │ └── tschuehly
│ │ │ └── htmx
│ │ │ └── spring
│ │ │ └── supabase
│ │ │ └── auth
│ │ │ ├── SupabaseAutoConfiguration.kt
│ │ │ ├── config
│ │ │ ├── NoDataBase.kt
│ │ │ └── SupabaseProperties.kt
│ │ │ ├── controller
│ │ │ └── SupabaseUserController.kt
│ │ │ ├── events
│ │ │ ├── SupabaseUserAuthenticated.kt
│ │ │ ├── SupabaseUserEmailUpdateConfirmed.kt
│ │ │ ├── SupabaseUserEmailUpdateRequested.kt
│ │ │ └── SupabaseUserRolesUpdated.kt
│ │ │ ├── exception
│ │ │ ├── AnonymousSignInDisabled.kt
│ │ │ ├── ClaimsCannotBeNullException.kt
│ │ │ ├── HxCurrentUrlHeaderNotFound.kt
│ │ │ ├── JWTTokenNullException.kt
│ │ │ ├── MissingServiceRoleForAdminAccessException.kt
│ │ │ ├── OtpExpiredException.kt
│ │ │ ├── OtpSignupNotAllowedExceptions.kt
│ │ │ ├── SupabaseAuthException.kt
│ │ │ ├── UnknownSupabaseException.kt
│ │ │ ├── ValidationFailedException.kt
│ │ │ ├── WeakPasswordException.kt
│ │ │ ├── email
│ │ │ │ ├── OtpEmailSent.kt
│ │ │ │ ├── PasswordRecoveryEmailSent.kt
│ │ │ │ ├── RegistrationConfirmationEmailSent.kt
│ │ │ │ └── SuccessfulPasswordUpdate.kt
│ │ │ ├── handler
│ │ │ │ └── SupabaseExceptionHandler.kt
│ │ │ └── info
│ │ │ │ ├── InvalidLoginCredentialsException.kt
│ │ │ │ ├── MissingCredentialsException.kt
│ │ │ │ ├── NewPasswordShouldBeDifferentFromOldPasswordException.kt
│ │ │ │ ├── UserAlreadyRegisteredException.kt
│ │ │ │ ├── UserNeedsToConfirmEmailBeforeLoginException.kt
│ │ │ │ └── UserNeedsToConfirmEmailForEmailChangeException.kt
│ │ │ ├── htmx
│ │ │ └── HtmxUtil.kt
│ │ │ ├── security
│ │ │ ├── JwtAuthenticationToken.kt
│ │ │ ├── SupabaseAccessDeniedHandler.kt
│ │ │ ├── SupabaseAuthenticationEntryPoint.kt
│ │ │ ├── SupabaseAuthenticationProvider.kt
│ │ │ ├── SupabaseAuthenticationToken.kt
│ │ │ ├── SupabaseJwtFilter.kt
│ │ │ ├── SupabaseJwtVerifier.kt
│ │ │ ├── SupabaseSecurityConfig.kt
│ │ │ └── SupabaseSecurityContextHolder.kt
│ │ │ ├── service
│ │ │ └── SupabaseUserService.kt
│ │ │ └── types
│ │ │ └── SupabaseUser.kt
│ └── resources
│ │ └── META-INF
│ │ ├── spring-configuration-metadata.json
│ │ └── spring
│ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── test
│ ├── kotlin
│ └── de
│ │ └── tschuehly
│ │ └── htmx
│ │ └── spring
│ │ └── supabase
│ │ └── auth
│ │ ├── application
│ │ ├── CustomExceptionHandlerExample.kt
│ │ ├── ExampleWebController.kt
│ │ └── TestApplication.kt
│ │ └── test
│ │ ├── SupabaseGoTrueTest.kt
│ │ ├── SupabaseHtmxTests.kt
│ │ └── mock
│ │ ├── GoTrueMock.kt
│ │ └── GoTrueMockConfiguration.kt
│ └── resources
│ ├── application.yaml
│ ├── fixtures
│ ├── expired-user-jwt.txt
│ ├── login-response.json
│ ├── service-role-user-jwt.txt
│ ├── set-roles-response.json
│ ├── settings-response.json
│ ├── signup-response.json
│ ├── user-response-email-disabled.json
│ └── valid-user-jwt.txt
│ ├── static
│ └── favicon.ico
│ ├── template.env
│ └── templates
│ ├── 403.html
│ ├── account.html
│ ├── admin.html
│ ├── error.html
│ ├── index.html
│ ├── requestPasswordReset.html
│ ├── resetPassword.html
│ ├── scripts.html
│ ├── unauthenticated.html
│ ├── unauthorized.html
│ └── updatePassword.html
└── supabase
├── .gitignore
└── config.toml
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish package to the Maven Central Repository
2 | on:
3 | push:
4 | branches:
5 | - '**SNAPSHOT'
6 | tags:
7 | - v*
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Set up Java
14 | uses: actions/setup-java@v4
15 | with:
16 | java-version: '22'
17 | distribution: 'liberica'
18 | - name: Run chmod to make gradlew executable
19 | run: chmod +x ./gradlew
20 | - name: Publish package to local staging directory
21 | run: ./gradlew :publish
22 | - name: Publish package to maven central
23 | env:
24 | JRELEASER_NEXUS2_USERNAME: ${{ secrets.JRELEASER_NEXUS2_USERNAME }}
25 | JRELEASER_NEXUS2_PASSWORD: ${{ secrets.JRELEASER_NEXUS2_PASSWORD }}
26 | JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_SONATYPE_USERNAME }}
27 | JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD: ${{ secrets.JRELEASER_MAVENCENTRAL_SONATYPE_PASSWORD }}
28 | JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }}
29 | JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }}
30 | JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.JRELEASER_GPG_PUBLIC_KEY }}
31 | JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 | run: ./gradlew :jreleaserDeploy --stacktrace -DaltDeploymentRepository=local::file:./build/staging-deploy
33 | - name: JReleaser release output
34 | if: always()
35 | uses: actions/upload-artifact@v4
36 | with:
37 | name: jreleaser-release
38 | path: |
39 | build/jreleaser/trace.log
40 | build/jreleaser/output.properties
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | HELP.md
2 | .gradle
3 | build/
4 | !gradle/wrapper/gradle-wrapper.jar
5 | !**/src/main/**/build/
6 | !**/src/test/**/build/
7 |
8 | ### STS ###
9 | .apt_generated
10 | .classpath
11 | .factorypath
12 | .project
13 | .settings
14 | .springBeans
15 | .sts4-cache
16 | bin/
17 | !**/src/main/**/bin/
18 | !**/src/test/**/bin/
19 |
20 | ### IntelliJ IDEA ###
21 | .idea
22 | *.iws
23 | *.iml
24 | *.ipr
25 | out/
26 | !**/src/main/**/out/
27 | !**/src/test/**/out/
28 |
29 | ### NetBeans ###
30 | /nbproject/private/
31 | /nbbuild/
32 | /dist/
33 | /nbdist/
34 | /.nb-gradle/
35 |
36 | ### VS Code ###
37 | .vscode/
38 | /src/test/resources/dev.env
39 | /node_modules
40 | /src/test/resources/test.env
41 | /src/test/resources/test.properties
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HTMX Supabase Spring Boot Starter
2 |
3 | Easy integration of [Supabase Authentication](https://supabase.com/auth) in your Spring Boot + [htmx](https://htmx.org/) project!
4 |
5 | Supabase gives us access to two important things for free:
6 |
7 | - Hosted Postgres Server with 500 MB Database Storage
8 | - GoTrue API for Authentication of up to 50.000 Monthly Active Users
9 |
10 | ## Features of htmx-supabase-spring-boot-starter
11 |
12 | - Supabase Authentication integration
13 | - Spring Security configuration with application.yaml/properties
14 | - Role-Based Access Control
15 | - Basic Authentication
16 |
17 | ## Initial Setup:
18 |
19 | Include the dependency in your build.gradle.kts. You can look up the newest version
20 | on [search.maven.org](https://search.maven.org/artifact/de.tschuehly/htmx-supabase-spring-boot-starter)
21 |
22 | ````kotlin
23 | dependencies {
24 | implementation("de.tschuehly:htmx-supabase-spring-boot-starter:LATEST_VERSION")
25 | }
26 | ````
27 |
28 | Go to [supabase.com](https://app.supabase.com/sign-up) and sign up for an account.
29 | Create a new Supabase project. Save your database password for later.
30 |
31 | Go to your Spring App and configure your application.yaml using the Supabase API credentials.
32 | You can find them at Project Settings -> API or `https://app.supabase.com/project/yourProjectId/settings/api`
33 |
34 | ```yaml
35 | supabase:
36 | projectId: yourProjectId
37 | anonKey: ${SUPABASE_ANON_KEY}
38 | jwtSecret: ${SUPABASE_JWT_SECRET}
39 | successfulLoginRedirectPage: "/account"
40 | passwordRecoveryPage: /updatePassword
41 | unauthenticatedPage: /unauthenticated
42 | unauthorizedPage: /unauthorizedPage
43 | sslOnly: false
44 | database:
45 | host: "aws-0-eu-central-1.pooler.supabase.com"
46 | password: ${SUPABASE_DATABASE_PASSWORD}
47 | ```
48 |
49 | ``anonKey``, ``jwtSecret`` and ``database.password`` are sensitive properties, you should set these with environment
50 | variables.
51 |
52 | You need to set the Site URL and the Redirect URL in your supabase dashboard as well.
53 | You can find them at Authentication -> URL Configuration.
54 | If you didn't mess with the ``server.port`` property you should set it to `http://localhost:8080`
55 |
56 | Now you can get started with integrating authentication. The Supabase PostgreSQL database is automatically configured
57 | for you.
58 |
59 | ## Configuring Public Authorization
60 |
61 | You can configure public accessible paths in your application.yaml with the property `supabase.public`. You can
62 | configure access for get,post,put,delete. This is the minimal configuration for getting started:
63 |
64 | ```yaml
65 | public:
66 | get:
67 | - "/unauthenticated"
68 | - "/unauthorized"
69 | - "/api/user/logout"
70 | post:
71 | - "/api/user/signup"
72 | - "/api/user/signInWithMagicLink"
73 | - "/api/user/login"
74 | - "/api/user/jwt"
75 | - "/api/user/sendPasswordResetEmail"
76 | ```
77 |
78 | ## Usage with HTMX
79 |
80 | This Library is heavily optimized for [HTMX](https://htmx.org/), an awesome Library to
81 | build [modern user interfaces](https://htmx.org/examples) with the simplicity and power of hypertext. htmx gives you
82 | access to [AJAX](https://htmx.org/docs#ajax), [CSS Transitions](https://htmx.org/docs#css_transitions), [WebSockets](https://htmx.org/docs#websockets)
83 | and [Server Sent Events](https://htmx.org/docs#sse) directly in HTML,
84 | using [attributes](https://htmx.org/reference#attributes)
85 |
86 | You need to add this little script snippet to your index page. This will authenticate with the API after logging in with
87 | Google / confirm your email
88 |
89 | ````html
90 |
96 | ````
97 |
98 | ### SignUp
99 |
100 | ````html
101 |
110 | ````
111 |
112 | You should get an email with a confirmation link, and if we click on that we get redirected to the page we specified with
113 | the property: `supabase.successfulLoginRedirectPage: "/account"`
114 |
115 | ### Login with E-Mail
116 |
117 | ````html
118 |
127 | ````
128 |
129 | ### Login with Social Provides
130 |
131 | You need to configure each social provider in your Supabase dashboard at Authentication -> Configuration -> Providers
132 |
133 | #### Google
134 |
135 | If you configured Google you can just insert a link to log in with Google
136 |
137 | ````html
138 | Sign In with Google
139 | ````
140 |
141 | ### Logout
142 |
143 | ````html
144 | Logout
145 | ````
146 |
147 | ## User facing messages and Exception Handling
148 |
149 | To show info/error messages to the user you will need to implement the `de.tschuehly.htmx.spring.supabase.auth.exception.handler.SupabaseExceptionHandler` interface.
150 |
151 | After successful registration a `RegistrationConfirmationEmailSent` Exception is thrown from the library
152 | that you handle by overriding the `handleSuccessfulRegistration` method.
153 |
154 | You can find an example of a custom Exception handler here:
155 |
156 | ```
157 | src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/application/CustomExceptionHandlerExample.kt
158 | ```
159 |
160 |
161 | ## Role-based access control
162 |
163 | You can create a role-based access control configuration right inside your application.yaml:
164 |
165 | ```yaml
166 | supabase:
167 | roles:
168 | admin:
169 | get:
170 | - "/admin/**"
171 | user:
172 | post:
173 | - "/user-feature-1/**"
174 | ```
175 |
176 | With this configuration, users with the Authority ROLE_ADMIN can access any endpoints under the /admin/** path, and any user with the Authority ROLE_USER can create POST request to the endpoints under the /user-feature-1/** path.
177 |
178 | You need to be able to set roles for a user, but there are two ways to do that:
179 |
180 | ### service role JWT
181 |
182 | When you go to your supabase project in the Project Settings -> API section, you can find the service_role secret.
183 | With this secret, you can set the role for any user. This way you can control user roles directly from your backend.
184 |
185 | Here is an example curl request that sets the role of the user with the id: `381c6358-22dd-4681-81e3-c79846117511` to `USER`
186 |
187 | ````shell
188 | curl -X PUT --location "http://localhost:8080/api/user/setRoles" \
189 | -H "Cookie: JWT=service_role_secret" \
190 | -H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" \
191 | -d "userId=381c6358-22dd-4681-81e3-c79846117511&roles=user"
192 | ````
193 |
194 | ### Superuser account
195 |
196 | You can also elevate a normal user account that will act as a "superuser" account. This user will also ignore Row Level Security!
197 |
198 | To do this you need to set the role of the user to "service_role" in the auth.users table;
199 |
200 | You can do this with the following SQL:
201 |
202 | ````sql
203 | update auth.users
204 | set role = 'service_role'
205 | where email = 'test@example.com';
206 |
207 | select *
208 | from auth.users;
209 | ````
210 |
211 | After executing this SQL, this account can set the roles of other users with this form:
212 |
213 | ````html
214 |
215 |
227 |
228 | ````
229 |
230 | You can also set the roles of a User with a little bit of SQL:
231 |
232 | ```postgresql
233 | UPDATE auth.users SET
234 | raw_app_meta_data = jsonb_set(raw_app_meta_data,'{roles}','["admin"]'::jsonb,true)
235 | where email = 'user@example.com';
236 | ```
237 |
238 |
239 | ## Basic Auth
240 |
241 | Some applications need to be configured with Basic Authentication, for example Prometheus does not support cookie based authentication.
242 |
243 | You can configure Basic Authentication using the supabase.basicAuth property.
244 | Then encrypt the password using the [Spring Boot CLI](https://docs.spring.io/spring-boot/docs/current/reference/html/cli.html)
245 |
246 | ```yaml
247 | supabase:
248 | basicAuth:
249 | enabled: true
250 | username: prometheus
251 | password: "{bcrypt}$2a$$LVUNCy8Lht68w7KA0nobWuwyzbW8AdF3bRC25glv7M12ACAZ4PT8u"
252 | roles:
253 | - "ADMIN"
254 | ```
255 |
256 | ## Customizing the datasource
257 |
258 | If you want to change the settings of the HikariCP connection pool you can do that using the `supabase.datasource property`.
259 |
260 | To change the maximum pool size for example:
261 | ```yaml
262 | supabase:
263 | datasource:
264 | maximum-pool-size: 30
265 | ```
266 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jreleaser.model.Active
2 |
3 | plugins {
4 | id("org.springframework.boot") version "3.4.5"
5 | id("io.spring.dependency-management") version "1.1.7"
6 | kotlin("jvm") version "2.0.0"
7 | kotlin("plugin.spring") version "2.0.0"
8 |
9 | id("maven-publish")
10 | id("org.jreleaser") version "1.14.0"
11 | id("signing")
12 | }
13 |
14 | group = "de.tschuehly"
15 | version = "0.3.6"
16 | java {
17 | toolchain {
18 | languageVersion = JavaLanguageVersion.of(24)
19 | }
20 | }
21 |
22 | repositories {
23 | mavenCentral()
24 | }
25 |
26 | dependencies {
27 | implementation("org.springframework.boot:spring-boot-starter-web")
28 | implementation("org.springframework.boot:spring-boot-starter-actuator")
29 | implementation("org.springframework.boot:spring-boot-starter-security")
30 |
31 | implementation("org.springframework:spring-jdbc")
32 |
33 | implementation("org.springframework.boot:spring-boot-autoconfigure")
34 | implementation("org.springframework:spring-context-support")
35 |
36 | implementation("jakarta.annotation:jakarta.annotation-api:3.0.0")
37 | implementation("com.auth0:java-jwt:4.5.0")
38 |
39 | implementation("io.github.jan-tennert.supabase:auth-kt:3.1.4")
40 | runtimeOnly("io.ktor:ktor-client-java:3.1.3")
41 | testRuntimeOnly("io.ktor:ktor-client-java:3.1.3")
42 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
43 | testImplementation("io.ktor:ktor-client-mock:3.1.3")
44 |
45 |
46 | implementation("org.jetbrains.kotlin:kotlin-reflect")
47 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
48 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
49 | implementation("io.github.wimdeblauwe:htmx-spring-boot:3.4.1")
50 |
51 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
52 |
53 | runtimeOnly("org.postgresql:postgresql")
54 |
55 | testImplementation("org.htmlunit:htmlunit:4.11.1")
56 | testImplementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
57 | testImplementation("org.springframework.boot:spring-boot-starter-thymeleaf")
58 | testImplementation("org.springframework.boot:spring-boot-starter-test")
59 | implementation("org.wiremock:wiremock:3.13.0")
60 | testImplementation("org.springframework.boot:spring-boot-devtools")
61 | testImplementation("com.russhwolf:multiplatform-settings-test:1.3.0")
62 |
63 | }
64 |
65 |
66 | kotlin {
67 | compilerOptions {
68 | freeCompilerArgs.addAll("-Xjsr305=strict")
69 | }
70 | }
71 | tasks.withType {
72 | useJUnitPlatform()
73 | }
74 |
75 |
76 | tasks {
77 | bootJar {
78 | enabled = false
79 | }
80 | }
81 |
82 | java {
83 | withJavadocJar()
84 | withSourcesJar()
85 | }
86 |
87 | tasks.jar {
88 | enabled = true
89 | // Remove `plain` postfix from jar file name
90 | archiveClassifier.set("")
91 | }
92 | publishing {
93 | publications {
94 | create("Maven") {
95 | from(components["java"])
96 | groupId = "de.tschuehly"
97 | artifactId = "htmx-supabase-spring-boot-starter"
98 | description = "Spring Security with htmx and supabase with ease"
99 | }
100 | withType {
101 | pom {
102 | packaging = "jar"
103 | name.set("htmx-supabase-spring-boot-starter")
104 | description.set("Spring Security with htmx and supabase with ease")
105 | url.set("https://github.com/tschuehly/htmx-supabase-spring-boot-starter")
106 | inceptionYear.set("2023")
107 | licenses {
108 | license {
109 | name.set("MIT license")
110 | url.set("https://opensource.org/licenses/MIT")
111 | }
112 | }
113 | developers {
114 | developer {
115 | id.set("tschuehly")
116 | name.set("Thomas Schuehly")
117 | email.set("thomas.schuehly@outlook.com")
118 | }
119 | }
120 | scm {
121 | connection.set("scm:git:git@github.com:tschuehly/htmx-supabase-spring-boot-starter.git")
122 | developerConnection.set("scm:git:ssh:git@github.com:tschuehly/htmx-supabase-spring-boot-starter.git")
123 | url.set("https://github.com/tschuehly/htmx-supabase-spring-boot-starter")
124 | }
125 | }
126 | }
127 | }
128 | repositories {
129 | maven {
130 | url = layout.buildDirectory.dir("staging-deploy").get().asFile.toURI()
131 | }
132 | }
133 | }
134 |
135 | jreleaser {
136 | project {
137 | copyright.set("Thomas Schuehly")
138 | }
139 | gitRootSearch.set(true)
140 | signing {
141 | active.set(Active.ALWAYS)
142 | armored.set(true)
143 | }
144 | deploy {
145 | maven {
146 | nexus2 {
147 | create("maven-central") {
148 | active.set(Active.ALWAYS)
149 | snapshotSupported.set(true)
150 | url.set("https://s01.oss.sonatype.org/service/local")
151 | snapshotUrl.set("https://s01.oss.sonatype.org/content/repositories/snapshots")
152 | closeRepository.set(true)
153 | releaseRepository.set(true)
154 | stagingRepositories.add("build/staging-deploy")
155 | }
156 | }
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tschuehly/htmx-supabase-spring-boot-starter/be2bc84aff3e4a5c88941b36d6ba3b2a97fab2bd/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | #
21 | # Gradle start up script for POSIX generated by Gradle.
22 | #
23 | # Important for running:
24 | #
25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
26 | # noncompliant, but you have some other compliant shell such as ksh or
27 | # bash, then to run this script, type that shell name before the whole
28 | # command line, like:
29 | #
30 | # ksh Gradle
31 | #
32 | # Busybox and similar reduced shells will NOT work, because this script
33 | # requires all of these POSIX shell features:
34 | # * functions;
35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
37 | # * compound commands having a testable exit status, especially «case»;
38 | # * various built-in commands including «command», «set», and «ulimit».
39 | #
40 | # Important for patching:
41 | #
42 | # (2) This script targets any POSIX shell, so it avoids extensions provided
43 | # by Bash, Ksh, etc; in particular arrays are avoided.
44 | #
45 | # The "traditional" practice of packing multiple parameters into a
46 | # space-separated string is a well documented source of bugs and security
47 | # problems, so this is (mostly) avoided, by progressively accumulating
48 | # options in "$@", and eventually passing that to Java.
49 | #
50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
52 | # see the in-line comments for details.
53 | #
54 | # There are tweaks for specific operating systems such as AIX, CygWin,
55 | # Darwin, MinGW, and NonStop.
56 | #
57 | # (3) This script is generated from the Groovy template
58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
59 | # within the Gradle project.
60 | #
61 | # You can find Gradle at https://github.com/gradle/gradle/.
62 | #
63 | ##############################################################################
64 |
65 | # Attempt to set APP_HOME
66 |
67 | # Resolve links: $0 may be a link
68 | app_path=$0
69 |
70 | # Need this for daisy-chained symlinks.
71 | while
72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
73 | [ -h "$app_path" ]
74 | do
75 | ls=$( ls -ld "$app_path" )
76 | link=${ls#*' -> '}
77 | case $link in #(
78 | /*) app_path=$link ;; #(
79 | *) app_path=$APP_HOME$link ;;
80 | esac
81 | done
82 |
83 | # This is normally unused
84 | # shellcheck disable=SC2034
85 | APP_BASE_NAME=${0##*/}
86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
88 |
89 | # Use the maximum available, or set MAX_FD != -1 to use that value.
90 | MAX_FD=maximum
91 |
92 | warn () {
93 | echo "$*"
94 | } >&2
95 |
96 | die () {
97 | echo
98 | echo "$*"
99 | echo
100 | exit 1
101 | } >&2
102 |
103 | # OS specific support (must be 'true' or 'false').
104 | cygwin=false
105 | msys=false
106 | darwin=false
107 | nonstop=false
108 | case "$( uname )" in #(
109 | CYGWIN* ) cygwin=true ;; #(
110 | Darwin* ) darwin=true ;; #(
111 | MSYS* | MINGW* ) msys=true ;; #(
112 | NONSTOP* ) nonstop=true ;;
113 | esac
114 |
115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
116 |
117 |
118 | # Determine the Java command to use to start the JVM.
119 | if [ -n "$JAVA_HOME" ] ; then
120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
121 | # IBM's JDK on AIX uses strange locations for the executables
122 | JAVACMD=$JAVA_HOME/jre/sh/java
123 | else
124 | JAVACMD=$JAVA_HOME/bin/java
125 | fi
126 | if [ ! -x "$JAVACMD" ] ; then
127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
128 |
129 | Please set the JAVA_HOME variable in your environment to match the
130 | location of your Java installation."
131 | fi
132 | else
133 | JAVACMD=java
134 | if ! command -v java >/dev/null 2>&1
135 | then
136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
137 |
138 | Please set the JAVA_HOME variable in your environment to match the
139 | location of your Java installation."
140 | fi
141 | fi
142 |
143 | # Increase the maximum file descriptors if we can.
144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
145 | case $MAX_FD in #(
146 | max*)
147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
148 | # shellcheck disable=SC2039,SC3045
149 | MAX_FD=$( ulimit -H -n ) ||
150 | warn "Could not query maximum file descriptor limit"
151 | esac
152 | case $MAX_FD in #(
153 | '' | soft) :;; #(
154 | *)
155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
156 | # shellcheck disable=SC2039,SC3045
157 | ulimit -n "$MAX_FD" ||
158 | warn "Could not set maximum file descriptor limit to $MAX_FD"
159 | esac
160 | fi
161 |
162 | # Collect all arguments for the java command, stacking in reverse order:
163 | # * args from the command line
164 | # * the main class name
165 | # * -classpath
166 | # * -D...appname settings
167 | # * --module-path (only if needed)
168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
169 |
170 | # For Cygwin or MSYS, switch paths to Windows format before running java
171 | if "$cygwin" || "$msys" ; then
172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -classpath "$CLASSPATH" \
214 | org.gradle.wrapper.GradleWrapperMain \
215 | "$@"
216 |
217 | # Stop when "xargs" is not available.
218 | if ! command -v xargs >/dev/null 2>&1
219 | then
220 | die "xargs is not available"
221 | fi
222 |
223 | # Use "xargs" to parse quoted args.
224 | #
225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
226 | #
227 | # In Bash we could simply go:
228 | #
229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
230 | # set -- "${ARGS[@]}" "$@"
231 | #
232 | # but POSIX shell has neither arrays nor command substitution, so instead we
233 | # post-process each arg (as a line of input to sed) to backslash-escape any
234 | # character that might be a shell metacharacter, then use eval to reverse
235 | # that process (while maintaining the separation between arguments), and wrap
236 | # the whole thing up as a single "set" statement.
237 | #
238 | # This will of course break if any of these variables contains a newline or
239 | # an unmatched quote.
240 | #
241 |
242 | eval "set -- $(
243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
244 | xargs -n1 |
245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
246 | tr '\n' ' '
247 | )" '"$@"'
248 |
249 | exec "$JAVACMD" "$@"
250 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | @rem This is normally unused
30 | set APP_BASE_NAME=%~n0
31 | set APP_HOME=%DIRNAME%
32 |
33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
35 |
36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
38 |
39 | @rem Find java.exe
40 | if defined JAVA_HOME goto findJavaFromJavaHome
41 |
42 | set JAVA_EXE=java.exe
43 | %JAVA_EXE% -version >NUL 2>&1
44 | if %ERRORLEVEL% equ 0 goto execute
45 |
46 | echo.
47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
48 | echo.
49 | echo Please set the JAVA_HOME variable in your environment to match the
50 | echo location of your Java installation.
51 |
52 | goto fail
53 |
54 | :findJavaFromJavaHome
55 | set JAVA_HOME=%JAVA_HOME:"=%
56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
57 |
58 | if exist "%JAVA_EXE%" goto execute
59 |
60 | echo.
61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
62 | echo.
63 | echo Please set the JAVA_HOME variable in your environment to match the
64 | echo location of your Java installation.
65 |
66 | goto fail
67 |
68 | :execute
69 | @rem Setup the command line
70 |
71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
72 |
73 |
74 | @rem Execute Gradle
75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
76 |
77 | :end
78 | @rem End local scope for the variables with windows NT shell
79 | if %ERRORLEVEL% equ 0 goto mainEnd
80 |
81 | :fail
82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
83 | rem the _cmd.exe /c_ return code!
84 | set EXIT_CODE=%ERRORLEVEL%
85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
87 | exit /b %EXIT_CODE%
88 |
89 | :mainEnd
90 | if "%OS%"=="Windows_NT" endlocal
91 |
92 | :omega
93 |
--------------------------------------------------------------------------------
/jitpack.yml:
--------------------------------------------------------------------------------
1 | jdk:
2 | - openjdk17
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "htmx-supabase-spring-boot-starter",
3 | "lockfileVersion": 3,
4 | "requires": true,
5 | "packages": {
6 | "": {
7 | "dependencies": {
8 | "supabase": "^1.178.2"
9 | }
10 | },
11 | "node_modules/@isaacs/cliui": {
12 | "version": "8.0.2",
13 | "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
14 | "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
15 | "license": "ISC",
16 | "dependencies": {
17 | "string-width": "^5.1.2",
18 | "string-width-cjs": "npm:string-width@^4.2.0",
19 | "strip-ansi": "^7.0.1",
20 | "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
21 | "wrap-ansi": "^8.1.0",
22 | "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
23 | },
24 | "engines": {
25 | "node": ">=12"
26 | }
27 | },
28 | "node_modules/@isaacs/fs-minipass": {
29 | "version": "4.0.1",
30 | "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
31 | "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
32 | "license": "ISC",
33 | "dependencies": {
34 | "minipass": "^7.0.4"
35 | },
36 | "engines": {
37 | "node": ">=18.0.0"
38 | }
39 | },
40 | "node_modules/@pkgjs/parseargs": {
41 | "version": "0.11.0",
42 | "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
43 | "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
44 | "license": "MIT",
45 | "optional": true,
46 | "engines": {
47 | "node": ">=14"
48 | }
49 | },
50 | "node_modules/agent-base": {
51 | "version": "7.1.1",
52 | "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
53 | "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==",
54 | "license": "MIT",
55 | "dependencies": {
56 | "debug": "^4.3.4"
57 | },
58 | "engines": {
59 | "node": ">= 14"
60 | }
61 | },
62 | "node_modules/ansi-regex": {
63 | "version": "6.0.1",
64 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
65 | "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
66 | "license": "MIT",
67 | "engines": {
68 | "node": ">=12"
69 | },
70 | "funding": {
71 | "url": "https://github.com/chalk/ansi-regex?sponsor=1"
72 | }
73 | },
74 | "node_modules/ansi-styles": {
75 | "version": "6.2.1",
76 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
77 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
78 | "license": "MIT",
79 | "engines": {
80 | "node": ">=12"
81 | },
82 | "funding": {
83 | "url": "https://github.com/chalk/ansi-styles?sponsor=1"
84 | }
85 | },
86 | "node_modules/balanced-match": {
87 | "version": "1.0.2",
88 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
89 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
90 | "license": "MIT"
91 | },
92 | "node_modules/bin-links": {
93 | "version": "4.0.4",
94 | "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.4.tgz",
95 | "integrity": "sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==",
96 | "license": "ISC",
97 | "dependencies": {
98 | "cmd-shim": "^6.0.0",
99 | "npm-normalize-package-bin": "^3.0.0",
100 | "read-cmd-shim": "^4.0.0",
101 | "write-file-atomic": "^5.0.0"
102 | },
103 | "engines": {
104 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
105 | }
106 | },
107 | "node_modules/brace-expansion": {
108 | "version": "2.0.1",
109 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
110 | "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
111 | "license": "MIT",
112 | "dependencies": {
113 | "balanced-match": "^1.0.0"
114 | }
115 | },
116 | "node_modules/chownr": {
117 | "version": "3.0.0",
118 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
119 | "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
120 | "license": "BlueOak-1.0.0",
121 | "engines": {
122 | "node": ">=18"
123 | }
124 | },
125 | "node_modules/cmd-shim": {
126 | "version": "6.0.3",
127 | "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.3.tgz",
128 | "integrity": "sha512-FMabTRlc5t5zjdenF6mS0MBeFZm0XqHqeOkcskKFb/LYCcRQ5fVgLOHVc4Lq9CqABd9zhjwPjMBCJvMCziSVtA==",
129 | "license": "ISC",
130 | "engines": {
131 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
132 | }
133 | },
134 | "node_modules/color-convert": {
135 | "version": "2.0.1",
136 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
137 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
138 | "license": "MIT",
139 | "dependencies": {
140 | "color-name": "~1.1.4"
141 | },
142 | "engines": {
143 | "node": ">=7.0.0"
144 | }
145 | },
146 | "node_modules/color-name": {
147 | "version": "1.1.4",
148 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
149 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
150 | "license": "MIT"
151 | },
152 | "node_modules/cross-spawn": {
153 | "version": "7.0.3",
154 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
155 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
156 | "license": "MIT",
157 | "dependencies": {
158 | "path-key": "^3.1.0",
159 | "shebang-command": "^2.0.0",
160 | "which": "^2.0.1"
161 | },
162 | "engines": {
163 | "node": ">= 8"
164 | }
165 | },
166 | "node_modules/data-uri-to-buffer": {
167 | "version": "4.0.1",
168 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
169 | "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
170 | "license": "MIT",
171 | "engines": {
172 | "node": ">= 12"
173 | }
174 | },
175 | "node_modules/debug": {
176 | "version": "4.3.5",
177 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
178 | "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
179 | "license": "MIT",
180 | "dependencies": {
181 | "ms": "2.1.2"
182 | },
183 | "engines": {
184 | "node": ">=6.0"
185 | },
186 | "peerDependenciesMeta": {
187 | "supports-color": {
188 | "optional": true
189 | }
190 | }
191 | },
192 | "node_modules/eastasianwidth": {
193 | "version": "0.2.0",
194 | "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
195 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
196 | "license": "MIT"
197 | },
198 | "node_modules/emoji-regex": {
199 | "version": "9.2.2",
200 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
201 | "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
202 | "license": "MIT"
203 | },
204 | "node_modules/fetch-blob": {
205 | "version": "3.2.0",
206 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
207 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
208 | "funding": [
209 | {
210 | "type": "github",
211 | "url": "https://github.com/sponsors/jimmywarting"
212 | },
213 | {
214 | "type": "paypal",
215 | "url": "https://paypal.me/jimmywarting"
216 | }
217 | ],
218 | "license": "MIT",
219 | "dependencies": {
220 | "node-domexception": "^1.0.0",
221 | "web-streams-polyfill": "^3.0.3"
222 | },
223 | "engines": {
224 | "node": "^12.20 || >= 14.13"
225 | }
226 | },
227 | "node_modules/foreground-child": {
228 | "version": "3.2.1",
229 | "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
230 | "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
231 | "license": "ISC",
232 | "dependencies": {
233 | "cross-spawn": "^7.0.0",
234 | "signal-exit": "^4.0.1"
235 | },
236 | "engines": {
237 | "node": ">=14"
238 | },
239 | "funding": {
240 | "url": "https://github.com/sponsors/isaacs"
241 | }
242 | },
243 | "node_modules/formdata-polyfill": {
244 | "version": "4.0.10",
245 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
246 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
247 | "license": "MIT",
248 | "dependencies": {
249 | "fetch-blob": "^3.1.2"
250 | },
251 | "engines": {
252 | "node": ">=12.20.0"
253 | }
254 | },
255 | "node_modules/glob": {
256 | "version": "10.4.5",
257 | "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
258 | "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
259 | "license": "ISC",
260 | "dependencies": {
261 | "foreground-child": "^3.1.0",
262 | "jackspeak": "^3.1.2",
263 | "minimatch": "^9.0.4",
264 | "minipass": "^7.1.2",
265 | "package-json-from-dist": "^1.0.0",
266 | "path-scurry": "^1.11.1"
267 | },
268 | "bin": {
269 | "glob": "dist/esm/bin.mjs"
270 | },
271 | "funding": {
272 | "url": "https://github.com/sponsors/isaacs"
273 | }
274 | },
275 | "node_modules/https-proxy-agent": {
276 | "version": "7.0.5",
277 | "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
278 | "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
279 | "license": "MIT",
280 | "dependencies": {
281 | "agent-base": "^7.0.2",
282 | "debug": "4"
283 | },
284 | "engines": {
285 | "node": ">= 14"
286 | }
287 | },
288 | "node_modules/imurmurhash": {
289 | "version": "0.1.4",
290 | "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
291 | "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
292 | "license": "MIT",
293 | "engines": {
294 | "node": ">=0.8.19"
295 | }
296 | },
297 | "node_modules/is-fullwidth-code-point": {
298 | "version": "3.0.0",
299 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
300 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
301 | "license": "MIT",
302 | "engines": {
303 | "node": ">=8"
304 | }
305 | },
306 | "node_modules/isexe": {
307 | "version": "2.0.0",
308 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
309 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
310 | "license": "ISC"
311 | },
312 | "node_modules/jackspeak": {
313 | "version": "3.4.2",
314 | "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.2.tgz",
315 | "integrity": "sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==",
316 | "license": "BlueOak-1.0.0",
317 | "dependencies": {
318 | "@isaacs/cliui": "^8.0.2"
319 | },
320 | "engines": {
321 | "node": "14 >=14.21 || 16 >=16.20 || >=18"
322 | },
323 | "funding": {
324 | "url": "https://github.com/sponsors/isaacs"
325 | },
326 | "optionalDependencies": {
327 | "@pkgjs/parseargs": "^0.11.0"
328 | }
329 | },
330 | "node_modules/lru-cache": {
331 | "version": "10.4.3",
332 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
333 | "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
334 | "license": "ISC"
335 | },
336 | "node_modules/minimatch": {
337 | "version": "9.0.5",
338 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
339 | "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
340 | "license": "ISC",
341 | "dependencies": {
342 | "brace-expansion": "^2.0.1"
343 | },
344 | "engines": {
345 | "node": ">=16 || 14 >=14.17"
346 | },
347 | "funding": {
348 | "url": "https://github.com/sponsors/isaacs"
349 | }
350 | },
351 | "node_modules/minipass": {
352 | "version": "7.1.2",
353 | "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
354 | "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
355 | "license": "ISC",
356 | "engines": {
357 | "node": ">=16 || 14 >=14.17"
358 | }
359 | },
360 | "node_modules/minizlib": {
361 | "version": "3.0.1",
362 | "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz",
363 | "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==",
364 | "license": "MIT",
365 | "dependencies": {
366 | "minipass": "^7.0.4",
367 | "rimraf": "^5.0.5"
368 | },
369 | "engines": {
370 | "node": ">= 18"
371 | }
372 | },
373 | "node_modules/mkdirp": {
374 | "version": "3.0.1",
375 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
376 | "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
377 | "license": "MIT",
378 | "bin": {
379 | "mkdirp": "dist/cjs/src/bin.js"
380 | },
381 | "engines": {
382 | "node": ">=10"
383 | },
384 | "funding": {
385 | "url": "https://github.com/sponsors/isaacs"
386 | }
387 | },
388 | "node_modules/ms": {
389 | "version": "2.1.2",
390 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
391 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
392 | "license": "MIT"
393 | },
394 | "node_modules/node-domexception": {
395 | "version": "1.0.0",
396 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
397 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
398 | "funding": [
399 | {
400 | "type": "github",
401 | "url": "https://github.com/sponsors/jimmywarting"
402 | },
403 | {
404 | "type": "github",
405 | "url": "https://paypal.me/jimmywarting"
406 | }
407 | ],
408 | "license": "MIT",
409 | "engines": {
410 | "node": ">=10.5.0"
411 | }
412 | },
413 | "node_modules/node-fetch": {
414 | "version": "3.3.2",
415 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
416 | "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
417 | "license": "MIT",
418 | "dependencies": {
419 | "data-uri-to-buffer": "^4.0.0",
420 | "fetch-blob": "^3.1.4",
421 | "formdata-polyfill": "^4.0.10"
422 | },
423 | "engines": {
424 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
425 | },
426 | "funding": {
427 | "type": "opencollective",
428 | "url": "https://opencollective.com/node-fetch"
429 | }
430 | },
431 | "node_modules/npm-normalize-package-bin": {
432 | "version": "3.0.1",
433 | "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
434 | "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
435 | "license": "ISC",
436 | "engines": {
437 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
438 | }
439 | },
440 | "node_modules/package-json-from-dist": {
441 | "version": "1.0.0",
442 | "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
443 | "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
444 | "license": "BlueOak-1.0.0"
445 | },
446 | "node_modules/path-key": {
447 | "version": "3.1.1",
448 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
449 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
450 | "license": "MIT",
451 | "engines": {
452 | "node": ">=8"
453 | }
454 | },
455 | "node_modules/path-scurry": {
456 | "version": "1.11.1",
457 | "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
458 | "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
459 | "license": "BlueOak-1.0.0",
460 | "dependencies": {
461 | "lru-cache": "^10.2.0",
462 | "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
463 | },
464 | "engines": {
465 | "node": ">=16 || 14 >=14.18"
466 | },
467 | "funding": {
468 | "url": "https://github.com/sponsors/isaacs"
469 | }
470 | },
471 | "node_modules/read-cmd-shim": {
472 | "version": "4.0.0",
473 | "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz",
474 | "integrity": "sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q==",
475 | "license": "ISC",
476 | "engines": {
477 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
478 | }
479 | },
480 | "node_modules/rimraf": {
481 | "version": "5.0.9",
482 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.9.tgz",
483 | "integrity": "sha512-3i7b8OcswU6CpU8Ej89quJD4O98id7TtVM5U4Mybh84zQXdrFmDLouWBEEaD/QfO3gDDfH+AGFCGsR7kngzQnA==",
484 | "license": "ISC",
485 | "dependencies": {
486 | "glob": "^10.3.7"
487 | },
488 | "bin": {
489 | "rimraf": "dist/esm/bin.mjs"
490 | },
491 | "engines": {
492 | "node": "14 >=14.20 || 16 >=16.20 || >=18"
493 | },
494 | "funding": {
495 | "url": "https://github.com/sponsors/isaacs"
496 | }
497 | },
498 | "node_modules/shebang-command": {
499 | "version": "2.0.0",
500 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
501 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
502 | "license": "MIT",
503 | "dependencies": {
504 | "shebang-regex": "^3.0.0"
505 | },
506 | "engines": {
507 | "node": ">=8"
508 | }
509 | },
510 | "node_modules/shebang-regex": {
511 | "version": "3.0.0",
512 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
513 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
514 | "license": "MIT",
515 | "engines": {
516 | "node": ">=8"
517 | }
518 | },
519 | "node_modules/signal-exit": {
520 | "version": "4.1.0",
521 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
522 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
523 | "license": "ISC",
524 | "engines": {
525 | "node": ">=14"
526 | },
527 | "funding": {
528 | "url": "https://github.com/sponsors/isaacs"
529 | }
530 | },
531 | "node_modules/string-width": {
532 | "version": "5.1.2",
533 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
534 | "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
535 | "license": "MIT",
536 | "dependencies": {
537 | "eastasianwidth": "^0.2.0",
538 | "emoji-regex": "^9.2.2",
539 | "strip-ansi": "^7.0.1"
540 | },
541 | "engines": {
542 | "node": ">=12"
543 | },
544 | "funding": {
545 | "url": "https://github.com/sponsors/sindresorhus"
546 | }
547 | },
548 | "node_modules/string-width-cjs": {
549 | "name": "string-width",
550 | "version": "4.2.3",
551 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
552 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
553 | "license": "MIT",
554 | "dependencies": {
555 | "emoji-regex": "^8.0.0",
556 | "is-fullwidth-code-point": "^3.0.0",
557 | "strip-ansi": "^6.0.1"
558 | },
559 | "engines": {
560 | "node": ">=8"
561 | }
562 | },
563 | "node_modules/string-width-cjs/node_modules/ansi-regex": {
564 | "version": "5.0.1",
565 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
566 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
567 | "license": "MIT",
568 | "engines": {
569 | "node": ">=8"
570 | }
571 | },
572 | "node_modules/string-width-cjs/node_modules/emoji-regex": {
573 | "version": "8.0.0",
574 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
575 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
576 | "license": "MIT"
577 | },
578 | "node_modules/string-width-cjs/node_modules/strip-ansi": {
579 | "version": "6.0.1",
580 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
581 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
582 | "license": "MIT",
583 | "dependencies": {
584 | "ansi-regex": "^5.0.1"
585 | },
586 | "engines": {
587 | "node": ">=8"
588 | }
589 | },
590 | "node_modules/strip-ansi": {
591 | "version": "7.1.0",
592 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
593 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
594 | "license": "MIT",
595 | "dependencies": {
596 | "ansi-regex": "^6.0.1"
597 | },
598 | "engines": {
599 | "node": ">=12"
600 | },
601 | "funding": {
602 | "url": "https://github.com/chalk/strip-ansi?sponsor=1"
603 | }
604 | },
605 | "node_modules/strip-ansi-cjs": {
606 | "name": "strip-ansi",
607 | "version": "6.0.1",
608 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
609 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
610 | "license": "MIT",
611 | "dependencies": {
612 | "ansi-regex": "^5.0.1"
613 | },
614 | "engines": {
615 | "node": ">=8"
616 | }
617 | },
618 | "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
619 | "version": "5.0.1",
620 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
621 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
622 | "license": "MIT",
623 | "engines": {
624 | "node": ">=8"
625 | }
626 | },
627 | "node_modules/supabase": {
628 | "version": "1.183.5",
629 | "resolved": "https://registry.npmjs.org/supabase/-/supabase-1.183.5.tgz",
630 | "integrity": "sha512-PYhxHHoSaEJSoDDQ+SN8iumfSmVQ8cHmBFB/GKhKZV2rDcVAPqe7HiEje37IuXCenOSamdvN8jQ8548tcsq4xw==",
631 | "hasInstallScript": true,
632 | "license": "MIT",
633 | "dependencies": {
634 | "bin-links": "^4.0.3",
635 | "https-proxy-agent": "^7.0.2",
636 | "node-fetch": "^3.3.2",
637 | "tar": "7.4.0"
638 | },
639 | "bin": {
640 | "supabase": "bin/supabase"
641 | },
642 | "engines": {
643 | "npm": ">=8"
644 | }
645 | },
646 | "node_modules/tar": {
647 | "version": "7.4.0",
648 | "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.0.tgz",
649 | "integrity": "sha512-XQs0S8fuAkQWuqhDeCdMlJXDX80D7EOVLDPVFkna9yQfzS+PHKgfxcei0jf6/+QAWcjqrnC8uM3fSAnrQl+XYg==",
650 | "license": "ISC",
651 | "dependencies": {
652 | "@isaacs/fs-minipass": "^4.0.0",
653 | "chownr": "^3.0.0",
654 | "minipass": "^7.1.2",
655 | "minizlib": "^3.0.1",
656 | "mkdirp": "^3.0.1",
657 | "yallist": "^5.0.0"
658 | },
659 | "engines": {
660 | "node": ">=18"
661 | }
662 | },
663 | "node_modules/web-streams-polyfill": {
664 | "version": "3.3.3",
665 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
666 | "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
667 | "license": "MIT",
668 | "engines": {
669 | "node": ">= 8"
670 | }
671 | },
672 | "node_modules/which": {
673 | "version": "2.0.2",
674 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
675 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
676 | "license": "ISC",
677 | "dependencies": {
678 | "isexe": "^2.0.0"
679 | },
680 | "bin": {
681 | "node-which": "bin/node-which"
682 | },
683 | "engines": {
684 | "node": ">= 8"
685 | }
686 | },
687 | "node_modules/wrap-ansi": {
688 | "version": "8.1.0",
689 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
690 | "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
691 | "license": "MIT",
692 | "dependencies": {
693 | "ansi-styles": "^6.1.0",
694 | "string-width": "^5.0.1",
695 | "strip-ansi": "^7.0.1"
696 | },
697 | "engines": {
698 | "node": ">=12"
699 | },
700 | "funding": {
701 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
702 | }
703 | },
704 | "node_modules/wrap-ansi-cjs": {
705 | "name": "wrap-ansi",
706 | "version": "7.0.0",
707 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
708 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
709 | "license": "MIT",
710 | "dependencies": {
711 | "ansi-styles": "^4.0.0",
712 | "string-width": "^4.1.0",
713 | "strip-ansi": "^6.0.0"
714 | },
715 | "engines": {
716 | "node": ">=10"
717 | },
718 | "funding": {
719 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
720 | }
721 | },
722 | "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
723 | "version": "5.0.1",
724 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
725 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
726 | "license": "MIT",
727 | "engines": {
728 | "node": ">=8"
729 | }
730 | },
731 | "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
732 | "version": "4.3.0",
733 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
734 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
735 | "license": "MIT",
736 | "dependencies": {
737 | "color-convert": "^2.0.1"
738 | },
739 | "engines": {
740 | "node": ">=8"
741 | },
742 | "funding": {
743 | "url": "https://github.com/chalk/ansi-styles?sponsor=1"
744 | }
745 | },
746 | "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
747 | "version": "8.0.0",
748 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
749 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
750 | "license": "MIT"
751 | },
752 | "node_modules/wrap-ansi-cjs/node_modules/string-width": {
753 | "version": "4.2.3",
754 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
755 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
756 | "license": "MIT",
757 | "dependencies": {
758 | "emoji-regex": "^8.0.0",
759 | "is-fullwidth-code-point": "^3.0.0",
760 | "strip-ansi": "^6.0.1"
761 | },
762 | "engines": {
763 | "node": ">=8"
764 | }
765 | },
766 | "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
767 | "version": "6.0.1",
768 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
769 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
770 | "license": "MIT",
771 | "dependencies": {
772 | "ansi-regex": "^5.0.1"
773 | },
774 | "engines": {
775 | "node": ">=8"
776 | }
777 | },
778 | "node_modules/write-file-atomic": {
779 | "version": "5.0.1",
780 | "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz",
781 | "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
782 | "license": "ISC",
783 | "dependencies": {
784 | "imurmurhash": "^0.1.4",
785 | "signal-exit": "^4.0.1"
786 | },
787 | "engines": {
788 | "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
789 | }
790 | },
791 | "node_modules/yallist": {
792 | "version": "5.0.0",
793 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
794 | "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
795 | "license": "BlueOak-1.0.0",
796 | "engines": {
797 | "node": ">=18"
798 | }
799 | }
800 | }
801 | }
802 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "start": "supabase start",
4 | "stop": "supabase stop",
5 | "db reset": "supabase db reset"
6 | },
7 | "dependencies": {
8 | "supabase": "^1.178.2"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | rootProject.name = "htmx-supabase-spring-boot-starter"
2 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/SupabaseAutoConfiguration.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth
2 |
3 | import com.auth0.jwt.JWT
4 | import com.auth0.jwt.algorithms.Algorithm
5 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
6 | import de.tschuehly.htmx.spring.supabase.auth.controller.SupabaseUserController
7 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationProvider
8 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtVerifier
9 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseSecurityConfig
10 | import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService
11 | import io.github.jan.supabase.auth.Auth
12 | import io.github.jan.supabase.auth.auth
13 | import io.github.jan.supabase.createSupabaseClient
14 | import org.slf4j.Logger
15 | import org.slf4j.LoggerFactory
16 | import org.springframework.boot.autoconfigure.AutoConfigureBefore
17 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
18 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
19 | import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
20 | import org.springframework.boot.context.properties.ConfigurationProperties
21 | import org.springframework.boot.context.properties.EnableConfigurationProperties
22 | import org.springframework.boot.jdbc.DataSourceBuilder
23 | import org.springframework.context.ApplicationEventPublisher
24 | import org.springframework.context.annotation.Bean
25 | import org.springframework.context.annotation.Configuration
26 | import org.springframework.context.annotation.Import
27 | import javax.sql.DataSource
28 |
29 | @Configuration
30 | @EnableConfigurationProperties(SupabaseProperties::class)
31 | @Import(SupabaseSecurityConfig::class)
32 | @AutoConfigureBefore(DataSourceAutoConfiguration::class)
33 | class SupabaseAutoConfiguration(
34 | val supabaseProperties: SupabaseProperties,
35 | ) {
36 | val logger: Logger =
37 | LoggerFactory.getLogger(SupabaseAutoConfiguration::class.java)
38 |
39 | @Bean
40 | @ConditionalOnMissingBean
41 | fun supabaseService(
42 | goTrueClient: Auth,
43 | supabaseAuthenticationProvider: SupabaseAuthenticationProvider,
44 | applicationEventPublisher: ApplicationEventPublisher
45 | ): SupabaseUserService {
46 | logger.debug("Registering the SupabaseUserService")
47 | return SupabaseUserService(
48 | supabaseProperties,
49 | goTrueClient,
50 | applicationEventPublisher,
51 | supabaseAuthenticationProvider
52 | )
53 | }
54 |
55 | @Bean
56 | @ConditionalOnMissingBean
57 | fun supabaseController(supabaseUserService: SupabaseUserService): SupabaseUserController {
58 | logger.debug("Registering the SupabaseUserController")
59 | return SupabaseUserController(supabaseUserService)
60 | }
61 |
62 | @Bean
63 | @ConditionalOnMissingBean
64 | fun supabaseClient(supabaseProperties: SupabaseProperties): Auth {
65 | val supabase = createSupabaseClient(
66 | supabaseUrl = supabaseProperties.url ?: "https://${supabaseProperties.projectId}.supabase.co",
67 | supabaseKey = supabaseProperties.anonKey ?: throw IllegalStateException()
68 | ) {
69 | install(Auth) {
70 | autoSaveToStorage = false
71 | autoLoadFromStorage = false
72 | alwaysAutoRefresh = false
73 | }
74 | }
75 | return supabase.auth
76 | }
77 |
78 | @Bean
79 | fun supabaseJwtVerifier(supabaseProperties: SupabaseProperties): SupabaseJwtVerifier {
80 | val jwtVerifier = JWT.require(Algorithm.HMAC256(supabaseProperties.jwtSecret)).build()
81 | return SupabaseJwtVerifier(jwtVerifier)
82 | }
83 |
84 |
85 | @Bean
86 | @ConfigurationProperties("supabase.datasource")
87 | @ConditionalOnProperty(prefix = "supabase.database", name = ["host"])
88 | fun dataSource(
89 | supabaseProperties: SupabaseProperties
90 | ): DataSource {
91 | val dataSourceBuilder = DataSourceBuilder.create()
92 | dataSourceBuilder.driverClassName("org.postgresql.Driver")
93 | supabaseProperties.database?.let { db ->
94 | dataSourceBuilder.url("jdbc:postgresql://${db.host}:${db.port}/${db.name}")
95 | db.username?.let {
96 | dataSourceBuilder.username(it)
97 | } ?: let { dataSourceBuilder.username("postgres.${supabaseProperties.projectId}") }
98 | dataSourceBuilder.password(db.password)
99 | }
100 | return dataSourceBuilder.build()
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/NoDataBase.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.config
2 |
3 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration
4 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
5 | import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
6 | import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
7 |
8 |
9 | @ConditionalOnProperty(prefix = "supabase.database", name = ["host"], matchIfMissing = true)
10 | @EnableAutoConfiguration(exclude = [DataSourceAutoConfiguration::class, DataSourceTransactionManagerAutoConfiguration::class])
11 | class NoDataBase {
12 |
13 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/config/SupabaseProperties.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.config
2 |
3 | import org.springframework.boot.context.properties.ConfigurationProperties
4 |
5 |
6 | @ConfigurationProperties("supabase")
7 | class SupabaseProperties(
8 | val projectId: String,
9 | val url: String?,
10 | val anonKey: String?,
11 | val jwtSecret: String?,
12 | val cookieDomain: String?,
13 | val database: Database? = null,
14 | val otpCreateUser: Boolean = true,
15 | val successfulLoginRedirectPage: String?,
16 | val passwordRecoveryPage: String?,
17 | val unauthenticatedPage: String?,
18 | val unauthorizedPage: String?,
19 | val postLogoutPage: String?,
20 | val sslOnly: Boolean = true,
21 | val public: Public = Public(),
22 | val roles: MutableMap = mutableMapOf(),
23 | val basicAuth: BasicAuth = BasicAuth()
24 | ) {
25 |
26 | init {
27 | val errorMessage = mutableListOf()
28 | if (anonKey == null) {
29 | errorMessage.add("You need to specify the property: supabase.anonKey in your application.yaml")
30 | }
31 | if (jwtSecret == null) {
32 | errorMessage.add("You need to specify the property: supabase.jwtSecret in your application.yaml")
33 | }
34 | if (successfulLoginRedirectPage == null) {
35 | errorMessage.add("You need to specify the property: supabase.successfulLoginRedirectPage in your application.yaml")
36 | }
37 | if (passwordRecoveryPage == null) {
38 | errorMessage.add("You need to specify the property: supabase.passwordRecoveryPage in your application.yaml")
39 | }
40 | if (unauthenticatedPage == null) {
41 | errorMessage.add("You need to specify the property: supabase.unauthenticatedPage in your application.yaml")
42 | }
43 | if (unauthorizedPage == null) {
44 | errorMessage.add("You need to specify the property: supabase.unauthorizedPage in your application.yaml")
45 | }
46 | if (errorMessage.isNotEmpty()) {
47 | throw IllegalArgumentException(errorMessage.joinToString("\n"))
48 | }
49 | }
50 |
51 | class Database(
52 | val host: String?,
53 | val name: String = "postgres",
54 | val username: String?,
55 | val password: String?,
56 | val port: Int = 5432
57 | ) {
58 |
59 | }
60 |
61 | class BasicAuth(
62 | val enabled: Boolean = false,
63 | val username: String? = null,
64 | val password: String? = null,
65 | val roles: List = listOf()
66 |
67 | ) {
68 | init {
69 | if (enabled) {
70 | require(username != null) { "You need to specify the property: supabase.basicAuth.username in you application.yaml" }
71 | require(password != null) { "You need to specify the property: supabase.basicAuth.password in you application.yaml" }
72 | }
73 | }
74 |
75 | }
76 |
77 | class Public {
78 | var get: Array = arrayOf()
79 | var post: Array = arrayOf()
80 | var delete: Array = arrayOf()
81 | var put: Array = arrayOf()
82 | }
83 |
84 |
85 | class Role {
86 | var get: Array = arrayOf()
87 | var post: Array = arrayOf()
88 | var delete: Array = arrayOf()
89 | var put: Array = arrayOf()
90 |
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/controller/SupabaseUserController.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.controller
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.exception.info.MissingCredentialsException.Companion.MissingCredentials
4 | import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService
5 | import jakarta.servlet.http.HttpServletResponse
6 | import org.slf4j.Logger
7 | import org.slf4j.LoggerFactory
8 | import org.springframework.http.HttpStatus
9 | import org.springframework.stereotype.Controller
10 | import org.springframework.web.bind.annotation.*
11 | import org.springframework.web.server.ResponseStatusException
12 |
13 | @Controller
14 | @RequestMapping("api/user")
15 | class SupabaseUserController(
16 | val supabaseUserService: SupabaseUserService,
17 | ) {
18 | val logger: Logger = LoggerFactory.getLogger(SupabaseUserController::class.java)
19 |
20 |
21 | @PostMapping("/login")
22 | fun login(
23 | @RequestParam email: String?,
24 | @RequestParam password: String?,
25 | response: HttpServletResponse,
26 | ) {
27 | checkCredentialsAndExecute(email, password) { checkedEmail, checkedPassword ->
28 | logger.debug("User with the email $checkedEmail is trying to login")
29 | supabaseUserService.loginWithEmail(checkedEmail, checkedPassword)
30 | }
31 | }
32 |
33 | @PostMapping("/signup")
34 | fun signUp(
35 | @RequestParam email: String?,
36 | @RequestParam password: String?
37 | ) {
38 | checkCredentialsAndExecute(email, password) { checkedEmail, checkedPassword ->
39 | logger.debug("User with the email $checkedEmail is trying to signup")
40 | supabaseUserService.signUpWithEmail(checkedEmail, checkedPassword)
41 | }
42 | }
43 |
44 | @PostMapping("/loginAnon")
45 | fun anonSignIn() {
46 | supabaseUserService.signInAnonymously()
47 | }
48 |
49 | @PostMapping("/loginAnonWithEmail")
50 | @ResponseBody
51 | fun anonSignInWithEmail(@RequestParam email: String?) {
52 | if (email != null) {
53 | supabaseUserService.signInAnonymouslyWithEmail(email)
54 | } else {
55 | MissingCredentials.EMAIL_MISSING.throwExc()
56 | }
57 | }
58 |
59 |
60 | @PostMapping("/linkIdentity")
61 | fun linkIdentity(
62 | @RequestParam email: String?
63 | ) {
64 | if (email != null) {
65 | logger.debug("User with the email $email is linking an Anonymous User")
66 | supabaseUserService.requestEmailChange(email)
67 | } else {
68 | MissingCredentials.EMAIL_MISSING.throwExc()
69 | }
70 | }
71 |
72 | @PostMapping("/signInWithMagicLink")
73 | fun sendEmailOtp(
74 | @RequestParam email: String?
75 | ) {
76 | if (email != null) {
77 | logger.debug("User with the email $email is trying to sign in with a Magic Link")
78 | supabaseUserService.signInWithMagicLink(email)
79 | } else {
80 | MissingCredentials.EMAIL_MISSING.throwExc()
81 | }
82 | }
83 |
84 | @PostMapping("/confirmEmailOtp")
85 | @ResponseBody
86 | fun confirmEmailOtp(
87 | @RequestParam email: String?,
88 | @RequestParam otp: String?
89 | ) {
90 | if (email.isNullOrBlank()) {
91 | MissingCredentials.EMAIL_MISSING.throwExc()
92 | }
93 | if (otp.isNullOrBlank()) {
94 | MissingCredentials.OTP_MISSING.throwExc()
95 | }
96 | logger.debug("User with the email $email is confirming an OTP")
97 | supabaseUserService.confirmEmailOtp(email!!, otp!!)
98 | }
99 |
100 |
101 | private fun checkCredentialsAndExecute(
102 | email: String?, password: String?,
103 | function: (email: String, password: String) -> Unit
104 | ) {
105 | when {
106 | email.isNullOrBlank() && password.isNullOrBlank() ->
107 | MissingCredentials.PASSWORD_AND_EMAIL_MISSING.throwExc()
108 |
109 | email.isNullOrBlank() ->
110 | MissingCredentials.EMAIL_MISSING.throwExc()
111 |
112 | password.isNullOrBlank() ->
113 | MissingCredentials.PASSWORD_MISSING.throwExc()
114 |
115 | else ->
116 | function(email.trim(), password.trim())
117 | }
118 | }
119 |
120 | @PostMapping("/jwt")
121 | @ResponseBody
122 | fun authorizeWithJwtOrResetPassword() {
123 | supabaseUserService.handleClientAuthentication()
124 | }
125 |
126 | @GetMapping("/logout")
127 | @ResponseBody
128 | fun logout() {
129 | supabaseUserService.logout()
130 | }
131 |
132 | @PutMapping("/setRoles")
133 | @ResponseBody
134 | fun setRoles(
135 | @RequestParam
136 | roles: List?,
137 | @RequestParam
138 | userId: String,
139 | ) {
140 | if (userId == "") {
141 | throw ResponseStatusException(HttpStatus.BAD_REQUEST, "UserId required")
142 | }
143 | supabaseUserService.setRolesWithRequest(userId, roles)
144 | }
145 |
146 | @PostMapping("/sendPasswordResetEmail")
147 | @ResponseBody
148 | fun sendPasswordResetEmail(
149 | @RequestParam
150 | email: String
151 | ) {
152 | logger.debug("User with the email $email requested a password reset")
153 | supabaseUserService.sendPasswordRecoveryEmail(email)
154 | }
155 |
156 | @PostMapping("/updatePassword")
157 | @ResponseBody
158 | fun updatePassword(@RequestParam password: String) {
159 | supabaseUserService.updatePassword(password)
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/events/SupabaseUserAuthenticated.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.events
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
4 |
5 |
6 | data class SupabaseUserAuthenticated(val user: SupabaseUser, val anonEmail: String? = null) {
7 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/events/SupabaseUserEmailUpdateConfirmed.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.events
2 |
3 | import java.util.*
4 |
5 | data class SupabaseUserEmailUpdateConfirmed(val id: UUID, val email: String) {
6 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/events/SupabaseUserEmailUpdateRequested.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.events
2 |
3 | import java.util.*
4 |
5 | data class SupabaseUserEmailUpdateRequested(val id: UUID, val email: String) {
6 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/events/SupabaseUserRolesUpdated.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.events
2 |
3 | data class SupabaseUserRolesUpdated(val id: String, val roles: List) {
4 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/AnonymousSignInDisabled.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class AnonymousSignInDisabled :
4 | Exception("You need to enable anonymous signIn to allow unauthenticated users to access the application") {
5 |
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/ClaimsCannotBeNullException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class ClaimsCannotBeNullException(message: String) : Exception(message) {
4 |
5 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/HxCurrentUrlHeaderNotFound.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class HxCurrentUrlHeaderNotFound : Exception("No HX-Current-URL header found while calling the /jwt endpoint")
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/JWTTokenNullException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class JWTTokenNullException(message: String) : Exception(message) {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/MissingServiceRoleForAdminAccessException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | import java.util.*
4 |
5 | class MissingServiceRoleForAdminAccessException(userId: UUID?) :
6 | Exception("User with id: $userId has tried to setRoles without having service_role") {
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/OtpExpiredException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class OtpExpiredException(val email: String) : Exception("The One Time Password is expired for email: $email") {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/OtpSignupNotAllowedExceptions.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class OtpSignupNotAllowedExceptions(message: String) : Exception(message)
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/SupabaseAuthException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | import io.github.jan.supabase.auth.exception.AuthRestException
4 |
5 |
6 | class SupabaseAuthException(exc: AuthRestException) :
7 | Exception("Supabase failed: ${exc.error} with message: ${exc.message}, code: ${exc.errorCode}", exc) {
8 | val errorCode = exc.errorCode
9 | val error: String = exc.error
10 | }
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/UnknownSupabaseException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class UnknownSupabaseException(supabaseMessage: String = "Something went wrong when communicating with Supabase") :
4 | Exception(supabaseMessage)
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/ValidationFailedException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class ValidationFailedException(val email: String) : Exception("The validation for the email failed: $email") {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/WeakPasswordException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception
2 |
3 | class WeakPasswordException(email: String) : Exception("Weak password for email: $email") {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/email/OtpEmailSent.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.email
2 |
3 | class OtpEmailSent(email: String) : Exception("OTP sent to $email")
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/email/PasswordRecoveryEmailSent.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.email
2 |
3 | class PasswordRecoveryEmailSent(message: String) : Exception(message) {
4 |
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/email/RegistrationConfirmationEmailSent.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.email
2 |
3 | import kotlinx.datetime.Instant
4 |
5 |
6 | class RegistrationConfirmationEmailSent(email: String, confirmationSentAt: Instant?) :
7 | Exception(
8 | "User with the mail $email successfully signed up, " +
9 | "${confirmationSentAt?.let { "Confirmation Mail sent at $it" }}"
10 | )
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/email/SuccessfulPasswordUpdate.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.email
2 |
3 | class SuccessfulPasswordUpdate(email: String?) :
4 | Exception("User with the mail: $email updated his password successfully") {
5 |
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/handler/SupabaseExceptionHandler.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.handler
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.exception.*
4 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.OtpEmailSent
5 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.PasswordRecoveryEmailSent
6 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.RegistrationConfirmationEmailSent
7 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.SuccessfulPasswordUpdate
8 | import de.tschuehly.htmx.spring.supabase.auth.exception.info.*
9 | import org.springframework.web.bind.annotation.ExceptionHandler
10 |
11 | interface SupabaseExceptionHandler {
12 | @ExceptionHandler(MissingCredentialsException::class)
13 | fun handleMissingCredentialsException(exception: MissingCredentialsException): Any
14 |
15 | @ExceptionHandler(InvalidLoginCredentialsException::class)
16 | fun handleInvalidLoginCredentialsException(exception: InvalidLoginCredentialsException): Any
17 |
18 | @ExceptionHandler(UserNeedsToConfirmEmailBeforeLoginException::class)
19 | fun handleUserNeedsToConfirmEmailBeforeLogin(exception: UserNeedsToConfirmEmailBeforeLoginException): Any
20 |
21 | @ExceptionHandler(UserNeedsToConfirmEmailForEmailChangeException::class)
22 | fun handleUserNeedsToConfirmEmailForEmailChange(exception: UserNeedsToConfirmEmailForEmailChangeException): Any
23 |
24 | @ExceptionHandler(RegistrationConfirmationEmailSent::class)
25 | fun handleSuccessfulRegistration(exception: RegistrationConfirmationEmailSent): Any
26 |
27 | @ExceptionHandler(PasswordRecoveryEmailSent::class)
28 | fun handlePasswordRecoveryEmailSent(exception: PasswordRecoveryEmailSent): Any
29 |
30 | @ExceptionHandler(SuccessfulPasswordUpdate::class)
31 | fun handleSuccessfulPasswordUpdate(exception: SuccessfulPasswordUpdate): Any
32 |
33 | @ExceptionHandler(OtpEmailSent::class)
34 | fun handleOtpEmailSent(exception: OtpEmailSent): Any
35 |
36 | @ExceptionHandler(UserAlreadyRegisteredException::class)
37 | fun handleUserAlreadyRegisteredException(exception: UserAlreadyRegisteredException): Any
38 |
39 | @ExceptionHandler(WeakPasswordException::class)
40 | fun handleWeakPasswordException(exception: WeakPasswordException): Any
41 |
42 | @ExceptionHandler(NewPasswordShouldBeDifferentFromOldPasswordException::class)
43 | fun handlePasswordChangeError(exception: NewPasswordShouldBeDifferentFromOldPasswordException): Any
44 |
45 | @ExceptionHandler(MissingServiceRoleForAdminAccessException::class)
46 | fun handleMissingServiceRoleForAdminAccessException(exception: MissingServiceRoleForAdminAccessException): Any
47 |
48 | @ExceptionHandler(SupabaseAuthException::class)
49 | fun handleSupabaseAuthException(exception: SupabaseAuthException): Any
50 |
51 | @ExceptionHandler(UnknownSupabaseException::class)
52 | fun handleUnknownSupabaseException(exception: UnknownSupabaseException): Any
53 |
54 | @ExceptionHandler(OtpExpiredException::class)
55 | fun handleOtpExpiredException(exception: OtpExpiredException): Any
56 |
57 | @ExceptionHandler(ValidationFailedException::class)
58 | fun handleValidationFailedException(exception: ValidationFailedException): Any
59 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/InvalidLoginCredentialsException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class InvalidLoginCredentialsException(email: String) :
4 | Exception("User: $email has tried to login with invalid credentials") {
5 |
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/MissingCredentialsException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class MissingCredentialsException(message: String) : Exception(message) {
4 | companion object {
5 | enum class MissingCredentials(val message: String) {
6 | PASSWORD_AND_EMAIL_MISSING("User needs to submit both a password and a email"),
7 | EMAIL_MISSING("User needs to submit a email"),
8 | PASSWORD_MISSING("User needs to submit a password"),
9 | OTP_MISSING("User needs to submit a OTP");
10 |
11 | fun throwExc() {
12 | throw MissingCredentialsException(message)
13 | }
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/NewPasswordShouldBeDifferentFromOldPasswordException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class NewPasswordShouldBeDifferentFromOldPasswordException(email: String) :
4 | Exception("User: $email tried to set a new password that was the same as the old one") {
5 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/UserAlreadyRegisteredException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class UserAlreadyRegisteredException(val email: String) :
4 | Exception("User: $email has tried to sign up again, but he was already registered")
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/UserNeedsToConfirmEmailBeforeLoginException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class UserNeedsToConfirmEmailBeforeLoginException(email: String) :
4 | Exception("User: $email needs to confirm email before he can login")
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/exception/info/UserNeedsToConfirmEmailForEmailChangeException.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.exception.info
2 |
3 | class UserNeedsToConfirmEmailForEmailChangeException(email: String) :
4 | Exception("User: $email needs to confirm email")
5 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/htmx/HtmxUtil.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.htmx
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.exception.HxCurrentUrlHeaderNotFound
4 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader
5 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader
6 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxSwapType
7 | import jakarta.servlet.http.Cookie
8 | import jakarta.servlet.http.HttpServletRequest
9 | import jakarta.servlet.http.HttpServletResponse
10 | import org.springframework.web.context.request.RequestContextHolder
11 | import org.springframework.web.context.request.ServletRequestAttributes
12 |
13 | object HtmxUtil {
14 | fun idSelector(id: String): String {
15 | return "#$id"
16 | }
17 |
18 | fun retarget(cssSelector: String?) {
19 | setHeader(HtmxResponseHeader.HX_RETARGET.getValue(), cssSelector)
20 | }
21 |
22 | fun getCurrentUrl() = getRequest().getHeader("HX-Current-URL")
23 | ?: throw HxCurrentUrlHeaderNotFound()
24 |
25 | fun retargetToId(id: String) {
26 | val request: HttpServletRequest = getRequest()
27 | if (request.getHeader(HtmxRequestHeader.HX_REQUEST.value) != null) {
28 | setHeader(
29 | headerName = HtmxResponseHeader.HX_RETARGET.value,
30 | headerValue = if (id.startsWith("#")) id else "#$id"
31 | )
32 | }
33 | }
34 |
35 | fun swap(hxSwapType: HxSwapType) {
36 | setHeader(HtmxResponseHeader.HX_RESWAP.value, hxSwapType.value)
37 | }
38 |
39 |
40 | fun trigger(event: String?) {
41 | val header: String? = getResponse().getHeader(HtmxResponseHeader.HX_TRIGGER.value)
42 | setHeader(HtmxResponseHeader.HX_TRIGGER.value, listOfNotNull(header, event).joinToString(", "))
43 | }
44 |
45 |
46 | fun setHeader(headerName: String?, headerValue: String?) {
47 | getResponse().setHeader(headerName, headerValue)
48 | }
49 |
50 | fun setHeader(htmxResponseHeader: HtmxResponseHeader, headerValue: String?) {
51 | getResponse().setHeader(htmxResponseHeader.value, headerValue)
52 | }
53 |
54 | fun isHtmxRequest(): Boolean {
55 | return getRequest().getHeader(HtmxRequestHeader.HX_REQUEST.value) == "true"
56 | }
57 |
58 | fun getCookie(name: String): Cookie? {
59 | return getRequest().cookies?.find { it.name == name }
60 | }
61 |
62 | fun getResponse(): HttpServletResponse {
63 | return (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.response
64 | ?: throw RuntimeException("No response found in RequestContextHolder")
65 | }
66 |
67 | fun getRequest(): HttpServletRequest {
68 | return (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
69 | ?: throw RuntimeException("No response found in RequestContextHolder")
70 | }
71 |
72 | fun redirect(path: String?) {
73 | if (path != null) {
74 | setHeader(HtmxResponseHeader.HX_REDIRECT, path)
75 | setHeader(HtmxResponseHeader.HX_RESWAP, HxSwapType.NONE.value)
76 | }
77 | }
78 |
79 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/JwtAuthenticationToken.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import org.springframework.security.authentication.AbstractAuthenticationToken
4 | import org.springframework.security.core.authority.AuthorityUtils
5 |
6 | class JwtAuthenticationToken(val jwtString: String):
7 | AbstractAuthenticationToken(AuthorityUtils.NO_AUTHORITIES) {
8 | override fun getCredentials(): String {
9 | return jwtString
10 | }
11 |
12 | override fun getPrincipal() = null
13 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseAccessDeniedHandler.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
4 | import jakarta.servlet.http.HttpServletRequest
5 | import jakarta.servlet.http.HttpServletResponse
6 | import org.slf4j.Logger
7 | import org.slf4j.LoggerFactory
8 | import org.springframework.security.access.AccessDeniedException
9 | import org.springframework.security.web.access.AccessDeniedHandler
10 |
11 | class SupabaseAccessDeniedHandler(
12 | private val supabaseProperties: SupabaseProperties
13 | ) : AccessDeniedHandler {
14 | private val logger: Logger = LoggerFactory.getLogger(SupabaseAccessDeniedHandler::class.java)
15 | override fun handle(
16 | request: HttpServletRequest,
17 | response: HttpServletResponse,
18 | accessDeniedException: AccessDeniedException
19 | ) {
20 | logger.debug(accessDeniedException.message)
21 | response.sendRedirect(supabaseProperties.unauthorizedPage)
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseAuthenticationEntryPoint.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
4 | import jakarta.servlet.http.HttpServletRequest
5 | import jakarta.servlet.http.HttpServletResponse
6 | import org.slf4j.Logger
7 | import org.slf4j.LoggerFactory
8 | import org.springframework.security.core.AuthenticationException
9 | import org.springframework.security.web.AuthenticationEntryPoint
10 |
11 | class SupabaseAuthenticationEntryPoint(
12 | private val supabaseProperties: SupabaseProperties
13 | ) : AuthenticationEntryPoint {
14 | private val logger: Logger = LoggerFactory.getLogger(SupabaseAuthenticationEntryPoint::class.java)
15 | override fun commence(
16 | request: HttpServletRequest,
17 | response: HttpServletResponse,
18 | authException: AuthenticationException
19 | ) {
20 | logger.debug("An unauthenticated User tried to access the path ${request.requestURI}")
21 | if (request.getHeader("HX-Request") == "true") {
22 | response.setHeader("HX-Redirect", supabaseProperties.unauthenticatedPage)
23 | return
24 | }
25 | response.sendRedirect(supabaseProperties.unauthenticatedPage)
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseAuthenticationProvider.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.exception.UnknownSupabaseException
4 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
5 | import org.springframework.security.authentication.AuthenticationProvider
6 | import org.springframework.security.core.Authentication
7 |
8 | class SupabaseAuthenticationProvider(
9 | private val supabaseJwtVerifier: SupabaseJwtVerifier
10 | ) : AuthenticationProvider {
11 | override fun authenticate(token: Authentication): SupabaseAuthenticationToken {
12 | token is JwtAuthenticationToken
13 | if (token !is JwtAuthenticationToken) {
14 | throw UnknownSupabaseException("Something went wrong when trying to authenticate with the jwt")
15 | }
16 | val jwt = supabaseJwtVerifier.verify(token.jwtString)
17 | return SupabaseAuthenticationToken.authenticated(SupabaseUser.createFromJWT(jwt))
18 | }
19 |
20 | override fun supports(authentication: Class<*>): Boolean {
21 | return authentication == JwtAuthenticationToken::class.java
22 | }
23 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseAuthenticationToken.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
4 | import org.springframework.security.authentication.AbstractAuthenticationToken
5 | import org.springframework.util.Assert
6 |
7 | class SupabaseAuthenticationToken(
8 | private val supabaseUser: SupabaseUser
9 | ) : AbstractAuthenticationToken(supabaseUser.getAuthorities()) {
10 |
11 | init {
12 | super.setAuthenticated(true)
13 | }
14 |
15 | companion object {
16 | fun authenticated(supabaseUser: SupabaseUser) = SupabaseAuthenticationToken(supabaseUser)
17 | }
18 |
19 | override fun getCredentials() = null
20 | override fun getPrincipal() = supabaseUser
21 |
22 | override fun setAuthenticated(authenticated: Boolean) {
23 | Assert.isTrue(
24 | !isAuthenticated,
25 | "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead"
26 | )
27 | super.setAuthenticated(false)
28 | }
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseJwtFilter.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import com.auth0.jwt.exceptions.IncorrectClaimException
4 | import com.auth0.jwt.exceptions.TokenExpiredException
5 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
6 | import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService
7 | import jakarta.servlet.FilterChain
8 | import jakarta.servlet.http.Cookie
9 | import jakarta.servlet.http.HttpServletRequest
10 | import jakarta.servlet.http.HttpServletResponse
11 | import org.springframework.web.filter.OncePerRequestFilter
12 |
13 |
14 | class SupabaseJwtFilter(
15 | private val supabaseProperties: SupabaseProperties,
16 | private val supabaseUserService: SupabaseUserService
17 | ) : OncePerRequestFilter() {
18 |
19 | override fun doFilterInternal(
20 | request: HttpServletRequest,
21 | response: HttpServletResponse,
22 | filterChain: FilterChain
23 | ) {
24 | val jwtString = getJwtString(request)
25 | if (jwtString != null) {
26 | try {
27 | supabaseUserService.authenticate(jwtString)
28 | } catch (e: TokenExpiredException) {
29 | response.setJWTCookie(jwtString, supabaseProperties, 0)
30 | } catch (e: IncorrectClaimException) {
31 | if (e.message?.contains("The Token can't be used before") == true) {
32 | // Wait for one second on login if the jwt is not active yet
33 | logger.debug(e.message)
34 | Thread.sleep(1000L)
35 | val user = supabaseUserService.authenticate(jwtString)
36 | }
37 | }
38 | }
39 | filterChain.doFilter(request, response)
40 | }
41 |
42 |
43 | private fun getJwtString(request: HttpServletRequest): String? {
44 |
45 | val cookie = request.cookies?.find { it.name == "JWT" }
46 | val header: String? = request.getHeader("HX-Current-URL")
47 | return if (header?.contains("#access_token=") == true) {
48 | header.substringBefore("&").substringAfter("#access_token=")
49 | } else {
50 | cookie?.value
51 | }
52 | }
53 |
54 | companion object {
55 | fun HttpServletResponse.setJWTCookie(
56 | accessToken: String,
57 | supabaseProperties: SupabaseProperties,
58 | maxAge: Int = 6000
59 | ) {
60 | this.addCookie(Cookie("JWT", accessToken).also {
61 | it.secure = supabaseProperties.sslOnly
62 | it.isHttpOnly = true
63 | if (supabaseProperties.cookieDomain != null) {
64 | it.domain = supabaseProperties.cookieDomain
65 | }
66 | it.path = "/"
67 | it.maxAge = maxAge
68 | })
69 | }
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseJwtVerifier.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import com.auth0.jwt.JWTVerifier
4 | import com.auth0.jwt.interfaces.Claim
5 |
6 | class SupabaseJwtVerifier(private val jwtVerifier: JWTVerifier) {
7 |
8 | fun verify(jwt: String): VerificationResult {
9 | val verifiedJwt = jwtVerifier.verify(jwt)
10 | return VerificationResult(verifiedJwt.claims, verifiedJwt.token)
11 | }
12 |
13 | data class VerificationResult(val claims: MutableMap, val token: String)
14 |
15 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityConfig.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
4 | import de.tschuehly.htmx.spring.supabase.auth.service.SupabaseUserService
5 | import org.slf4j.Logger
6 | import org.slf4j.LoggerFactory
7 | import org.springframework.context.annotation.Bean
8 | import org.springframework.context.annotation.Configuration
9 | import org.springframework.http.HttpMethod
10 | import org.springframework.security.authentication.AuthenticationManager
11 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
12 | import org.springframework.security.config.annotation.web.builders.HttpSecurity
13 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
14 | import org.springframework.security.config.annotation.web.invoke
15 | import org.springframework.security.config.http.SessionCreationPolicy
16 | import org.springframework.security.web.AuthenticationEntryPoint
17 | import org.springframework.security.web.SecurityFilterChain
18 | import org.springframework.security.web.access.AccessDeniedHandler
19 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
20 |
21 |
22 | @Configuration
23 | @EnableWebSecurity(debug = false)
24 | class SupabaseSecurityConfig(
25 | @Suppress("SpringJavaInjectionPointsAutowiringInspection")
26 | val supabaseProperties: SupabaseProperties
27 | ) {
28 | val logger: Logger = LoggerFactory.getLogger(SupabaseSecurityConfig::class.java)
29 |
30 | @Bean
31 | fun filterChain(
32 | http: HttpSecurity,
33 | supabaseJwtFilter: SupabaseJwtFilter,
34 | supabaseAuthenticationManager: AuthenticationManager
35 | ): SecurityFilterChain {
36 | supabaseProperties.roles.forEach { (role, paths) ->
37 | http.invoke {
38 | authorizeHttpRequests {
39 | paths.get.forEach { path ->
40 | logger.info("Path: $path with Method GET is secured with Expression: hasRole('$role')")
41 | authorize(path, hasRole(role.uppercase()))
42 | }
43 | paths.post.forEach { path ->
44 | logger.info("Path: $path with Method POST is secured with Expression: hasRole('$role')")
45 | authorize(path, hasRole(role.uppercase()))
46 | }
47 | paths.delete.forEach { path ->
48 | logger.info("Path: $path with Method DELETE is secured with Expression: hasRole('$role')")
49 | authorize(path, hasRole(role.uppercase()))
50 | }
51 | paths.put.forEach { path ->
52 | logger.info("Path: $path with Method PUT is secured with Expression: hasRole('$role')")
53 | authorize(path, hasRole(role.uppercase()))
54 | }
55 | }
56 | }
57 | }
58 |
59 | http.invoke {
60 | authorizeHttpRequests {
61 | supabaseProperties.public.get.forEach { path ->
62 | logger.info("Path: $path with Method GET is public")
63 | authorize(HttpMethod.GET, path, permitAll)
64 | }
65 | supabaseProperties.public.post.forEach { path ->
66 | logger.info("Path: $path with Method POST is public")
67 | authorize(HttpMethod.POST, path, permitAll)
68 | }
69 | supabaseProperties.public.delete.forEach { path ->
70 | logger.info("Path: $path with Method DELETE is public")
71 | authorize(HttpMethod.DELETE, path, permitAll)
72 | }
73 | supabaseProperties.public.put.forEach { path ->
74 | logger.info("Path: $path with Method PUT is public")
75 | authorize(HttpMethod.PUT, path, permitAll)
76 | }
77 |
78 | authorize(anyRequest, authenticated)
79 | }
80 | sessionManagement {
81 | sessionCreationPolicy = SessionCreationPolicy.STATELESS
82 | }
83 | authenticationManager = supabaseAuthenticationManager
84 | httpBasic {
85 | }
86 | csrf { disable() }
87 | headers {
88 | frameOptions {
89 | sameOrigin
90 | }
91 | }
92 | addFilterBefore(supabaseJwtFilter)
93 | exceptionHandling {
94 | authenticationEntryPoint = supabaseAuthenticationEntryPoint()
95 | accessDeniedHandler = supabaseAccessDeniedHandler()
96 | }
97 | }
98 |
99 | return http.build()
100 | }
101 |
102 | @Bean
103 | fun supabaseAccessDeniedHandler(): AccessDeniedHandler {
104 | return SupabaseAccessDeniedHandler(supabaseProperties)
105 | }
106 |
107 | @Bean
108 | fun supabaseAuthenticationEntryPoint(): AuthenticationEntryPoint {
109 | return SupabaseAuthenticationEntryPoint(supabaseProperties)
110 | }
111 |
112 | @Bean
113 | fun supabaseAuthenticationProvider(supabaseJwtVerifier: SupabaseJwtVerifier): SupabaseAuthenticationProvider {
114 | return SupabaseAuthenticationProvider(supabaseJwtVerifier)
115 | }
116 |
117 | @Bean
118 | fun supabaseJwtFilter(
119 | supabaseUserService: SupabaseUserService
120 | ): SupabaseJwtFilter {
121 | return SupabaseJwtFilter(supabaseProperties, supabaseUserService)
122 | }
123 |
124 | @Bean
125 | fun authManager(
126 | http: HttpSecurity,
127 | supabaseAuthenticationProvider: SupabaseAuthenticationProvider,
128 | supabaseProperties: SupabaseProperties
129 | ): AuthenticationManager {
130 | val authenticationManagerBuilder = http.getSharedObject(
131 | AuthenticationManagerBuilder::class.java
132 | )
133 | authenticationManagerBuilder.authenticationProvider(supabaseAuthenticationProvider)
134 | if (supabaseProperties.basicAuth.enabled) {
135 | authenticationManagerBuilder.inMemoryAuthentication()
136 | .withUser(supabaseProperties.basicAuth.username)
137 | .password(supabaseProperties.basicAuth.password)
138 | .roles(*supabaseProperties.basicAuth.roles.toTypedArray())
139 | }
140 | return authenticationManagerBuilder.build()
141 | }
142 |
143 | }
144 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/security/SupabaseSecurityContextHolder.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.security
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
4 | import org.springframework.security.authentication.AnonymousAuthenticationToken
5 | import org.springframework.security.core.context.SecurityContextHolder
6 |
7 | object SupabaseSecurityContextHolder {
8 | fun getAuthenticatedUser(): SupabaseUser? {
9 | val authentication = SecurityContextHolder.getContext().authentication
10 | if (authentication !is AnonymousAuthenticationToken) {
11 | return (authentication as SupabaseAuthenticationToken).principal
12 | }
13 | return null
14 | }
15 |
16 | }
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/service/SupabaseUserService.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.service
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties
4 | import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserAuthenticated
5 | import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserEmailUpdateConfirmed
6 | import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserEmailUpdateRequested
7 | import de.tschuehly.htmx.spring.supabase.auth.events.SupabaseUserRolesUpdated
8 | import de.tschuehly.htmx.spring.supabase.auth.exception.*
9 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.OtpEmailSent
10 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.PasswordRecoveryEmailSent
11 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.RegistrationConfirmationEmailSent
12 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.SuccessfulPasswordUpdate
13 | import de.tschuehly.htmx.spring.supabase.auth.exception.info.*
14 | import de.tschuehly.htmx.spring.supabase.auth.htmx.HtmxUtil
15 | import de.tschuehly.htmx.spring.supabase.auth.security.JwtAuthenticationToken
16 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationProvider
17 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtFilter.Companion.setJWTCookie
18 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseSecurityContextHolder
19 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
20 | import io.github.jan.supabase.auth.Auth
21 | import io.github.jan.supabase.auth.OtpType
22 | import io.github.jan.supabase.auth.exception.AuthErrorCode
23 | import io.github.jan.supabase.auth.exception.AuthRestException
24 | import io.github.jan.supabase.auth.providers.builtin.Email
25 | import io.github.jan.supabase.auth.providers.builtin.OTP
26 | import io.github.jan.supabase.auth.user.UserInfo
27 | import io.github.jan.supabase.exceptions.RestException
28 | import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_REDIRECT
29 | import kotlinx.coroutines.CoroutineScope
30 | import kotlinx.coroutines.runBlocking
31 | import kotlinx.serialization.json.add
32 | import kotlinx.serialization.json.buildJsonObject
33 | import kotlinx.serialization.json.putJsonArray
34 | import org.slf4j.Logger
35 | import org.slf4j.LoggerFactory
36 | import org.springframework.context.ApplicationEventPublisher
37 | import org.springframework.security.core.context.SecurityContext
38 | import org.springframework.security.core.context.SecurityContextHolder
39 | import org.springframework.security.web.context.RequestAttributeSecurityContextRepository
40 | import org.springframework.security.web.context.SecurityContextRepository
41 |
42 | class SupabaseUserService(
43 | private val supabaseProperties: SupabaseProperties,
44 | private val goTrueClient: Auth,
45 | private val applicationEventPublisher: ApplicationEventPublisher,
46 | private val authenticationManager: SupabaseAuthenticationProvider,
47 | ) {
48 |
49 | private val securityContextHolderStrategy = SecurityContextHolder
50 | .getContextHolderStrategy()
51 | private val securityContextRepository: SecurityContextRepository = RequestAttributeSecurityContextRepository()
52 | private val logger: Logger = LoggerFactory.getLogger(SupabaseUserService::class.java)
53 |
54 |
55 | fun signUpWithEmail(email: String, password: String) {
56 | runGoTrue(email) {
57 | val user = goTrueClient.signUpWith(Email) {
58 | this.email = email
59 | this.password = password
60 | }
61 | if (emailConfirmationEnabled(user)) {
62 | logger.debug("User $email signed up, email confirmation sent")
63 | throw RegistrationConfirmationEmailSent(email, user?.confirmationSentAt)
64 | }
65 | logger.debug("User $email successfully signed up")
66 | loginWithEmail(email, password)
67 | }
68 | }
69 |
70 | private fun emailConfirmationEnabled(user: UserInfo?) = user?.email != null
71 |
72 |
73 | fun loginWithEmail(email: String, password: String) {
74 | runGoTrue(email) {
75 | goTrueClient.signInWith(Email) {
76 | this.email = email
77 | this.password = password
78 | }
79 | val user = authenticateWithCurrentSession()
80 | applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user, email))
81 | logger.debug("User: $email successfully logged in")
82 | }
83 | }
84 |
85 |
86 | fun signInWithMagicLink(email: String) {
87 | runGoTrue(email) {
88 | goTrueClient.signInWith(OTP) {
89 | this.email = email
90 | this.createUser = supabaseProperties.otpCreateUser
91 | }
92 | throw OtpEmailSent(email)
93 | }
94 | }
95 |
96 | fun handleClientAuthentication(
97 | ) {
98 | val url = HtmxUtil.getCurrentUrl()
99 | val user = SupabaseSecurityContextHolder.getAuthenticatedUser()
100 | ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository")
101 | if (url.contains("type=recovery")) {
102 | logger.debug("User: ${user.email} is trying to reset his password")
103 | HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.passwordRecoveryPage)
104 | return
105 | }
106 | if (url.contains("type=email_change")) {
107 | logger.debug("User: ${user.email} has set email")
108 | val email = user.email ?: throw IllegalStateException("Email shouldn't be null")
109 | applicationEventPublisher.publishEvent(SupabaseUserEmailUpdateConfirmed(user.id, email))
110 | return
111 | }
112 | applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user))
113 | HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.successfulLoginRedirectPage)
114 | }
115 |
116 | fun signInAnonymously() {
117 | runGoTrue {
118 | goTrueClient.signInAnonymously()
119 | val user = authenticateWithCurrentSession()
120 | applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user))
121 | }
122 | }
123 |
124 | fun requestEmailChange(email: String) {
125 | runGoTrue {
126 | val user = SupabaseSecurityContextHolder.getAuthenticatedUser()
127 | ?: throw UnknownSupabaseException("No authenticated user found in SecurityContext")
128 | goTrueClient.importAuthToken(user.verifiedJwt)
129 | goTrueClient.updateUser {
130 | this.email = email
131 | }
132 | // TODO:
133 | if (user.email == email) {
134 | goTrueClient.resendEmail(OtpType.Email.EMAIL_CHANGE, email)
135 | }
136 | applicationEventPublisher.publishEvent(SupabaseUserEmailUpdateRequested(user.id, email))
137 | throw UserNeedsToConfirmEmailForEmailChangeException(email)
138 | }
139 | }
140 |
141 | fun confirmEmailOtp(email: String, otp: String) {
142 | runGoTrue(email) {
143 | val user = SupabaseSecurityContextHolder.getAuthenticatedUser()
144 | ?: throw UnknownSupabaseException("No authenticated user found in SecurityContext")
145 | goTrueClient.importAuthToken(user.verifiedJwt)
146 | goTrueClient.verifyEmailOtp(type = OtpType.Email.EMAIL_CHANGE, email = email, token = otp)
147 | applicationEventPublisher.publishEvent(SupabaseUserEmailUpdateConfirmed(user.id, email))
148 | }
149 | }
150 |
151 | fun resendEmailChangeConfirmation(email: String) {
152 | runGoTrue(email) {
153 | goTrueClient.resendEmail(OtpType.Email.EMAIL_CHANGE, email)
154 | }
155 | }
156 |
157 | fun signInAnonymouslyWithEmail(email: String) {
158 | runGoTrue(email) {
159 | goTrueClient.signInAnonymously()
160 | goTrueClient.updateUser {
161 | this.email = email
162 | }
163 | val user = authenticateWithCurrentSession()
164 | applicationEventPublisher.publishEvent(SupabaseUserAuthenticated(user, email))
165 | applicationEventPublisher.publishEvent(SupabaseUserEmailUpdateRequested(user.id, email))
166 | }
167 | }
168 |
169 | private fun authenticateWithCurrentSession(): SupabaseUser {
170 | val token = goTrueClient.currentSessionOrNull()?.accessToken
171 | ?: throw JWTTokenNullException("The JWT that requested from supabase is null")
172 | HtmxUtil.getResponse().setJWTCookie(token, supabaseProperties)
173 | HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.successfulLoginRedirectPage)
174 | return authenticate(token)
175 |
176 | }
177 |
178 | fun authenticate(jwt: String): SupabaseUser {
179 | val authResult = authenticationManager.authenticate(JwtAuthenticationToken(jwt))
180 | val context: SecurityContext = securityContextHolderStrategy.createEmptyContext()
181 | context.authentication = authResult
182 | HtmxUtil.getResponse().setJWTCookie(jwt, supabaseProperties)
183 | securityContextRepository.saveContext(context, HtmxUtil.getRequest(), HtmxUtil.getResponse())
184 | SecurityContextHolder.setContext(context)
185 | return authResult.principal
186 | }
187 |
188 | fun logout() {
189 | SecurityContextHolder.getContext().authentication = null
190 | val jwt = HtmxUtil.getCookie("JWT")?.value
191 | if (jwt != null) {
192 | HtmxUtil.getResponse().setJWTCookie(jwt, supabaseProperties, 0)
193 | HtmxUtil.setHeader(HX_REDIRECT, supabaseProperties.postLogoutPage ?: "/")
194 | }
195 | }
196 |
197 |
198 | fun setRolesWithRequest(userId: String, roles: List?) {
199 | HtmxUtil.getCookie("JWT")?.let {
200 | setRoles(it.value, userId, roles)
201 | }
202 | }
203 |
204 | private fun setRoles(serviceRoleJWT: String, userId: String, roles: List?) {
205 | val roleArray = roles ?: listOf()
206 | runGoTrue() {
207 | goTrueClient.importAuthToken(serviceRoleJWT)
208 | goTrueClient.admin.updateUserById(uid = userId) {
209 | appMetadata = buildJsonObject {
210 | putJsonArray("roles") {
211 | roleArray.map { add(it) }
212 | }
213 | }
214 | }
215 | applicationEventPublisher.publishEvent(SupabaseUserRolesUpdated(userId, roleArray))
216 | logger.debug("The roles of the user with id {} were updated to {}", userId, roleArray)
217 | }
218 | }
219 |
220 | fun sendPasswordRecoveryEmail(email: String) {
221 | runGoTrue(email) {
222 | goTrueClient.resetPasswordForEmail(email)
223 | throw PasswordRecoveryEmailSent("User with $email has requested a password recovery email")
224 | }
225 | }
226 |
227 | fun updatePassword(password: String) {
228 | val user = SupabaseSecurityContextHolder.getAuthenticatedUser()
229 | ?: throw UnknownSupabaseException("No authenticated user found in SecurityContextRepository")
230 | val email = user.email ?: "no-email"
231 | runGoTrue(email) {
232 | val jwt = HtmxUtil.getCookie("JWT")?.value
233 | ?: throw JWTTokenNullException("No JWT found in request")
234 | goTrueClient.importAuthToken(jwt)
235 | goTrueClient.updateUser {
236 | this.password = password
237 | }
238 | throw SuccessfulPasswordUpdate(user.email)
239 | }
240 | }
241 |
242 | private fun runGoTrue(
243 | email: String = "no-email",
244 | block: suspend CoroutineScope.() -> Unit
245 | ) {
246 | runBlocking {
247 | try {
248 | block()
249 | } catch (exc: AuthRestException) {
250 | handleAuthException(exc, email)
251 | } catch (e: RestException) {
252 | handleGoTrueException(e, email)
253 | } finally {
254 | goTrueClient.clearSession()
255 | }
256 | }
257 | }
258 |
259 | private fun handleAuthException(exc: AuthRestException, email: String) {
260 | when (exc.errorCode) {
261 | AuthErrorCode.EmailExists -> throw UserAlreadyRegisteredException(email)
262 | AuthErrorCode.UserAlreadyExists -> throw UserAlreadyRegisteredException(email)
263 | AuthErrorCode.SamePassword -> throw NewPasswordShouldBeDifferentFromOldPasswordException(email)
264 | AuthErrorCode.WeakPassword -> throw WeakPasswordException(email)
265 | AuthErrorCode.OtpExpired -> throw OtpExpiredException(email)
266 | AuthErrorCode.ValidationFailed -> throw ValidationFailedException(email)
267 | AuthErrorCode.NotAdmin -> throw MissingServiceRoleForAdminAccessException(SupabaseSecurityContextHolder.getAuthenticatedUser()?.id)
268 | else -> throw SupabaseAuthException(exc)
269 | }
270 | }
271 |
272 | private fun handleGoTrueException(e: RestException, email: String) {
273 | val message = e.message ?: let {
274 | logger.error(e.message)
275 | throw UnknownSupabaseException()
276 | }
277 | when {
278 | message.contains("Anonymous sign-ins are disabled", true) -> throw AnonymousSignInDisabled()
279 | message.contains("Invalid login credentials", true) -> throw InvalidLoginCredentialsException(email)
280 | message.contains("Email not confirmed", true) -> throw UserNeedsToConfirmEmailBeforeLoginException(email)
281 | message.contains("Signups not allowed for otp", true) -> throw OtpSignupNotAllowedExceptions(message)
282 | }
283 | logger.error(e.message)
284 | throw UnknownSupabaseException()
285 | }
286 |
287 | }
288 |
--------------------------------------------------------------------------------
/src/main/kotlin/de/tschuehly/htmx/spring/supabase/auth/types/SupabaseUser.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.types
2 |
3 | import com.auth0.jwt.interfaces.Claim
4 | import de.tschuehly.htmx.spring.supabase.auth.exception.ClaimsCannotBeNullException
5 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseJwtVerifier
6 | import org.springframework.security.core.GrantedAuthority
7 | import org.springframework.security.core.authority.AuthorityUtils
8 | import java.util.*
9 |
10 |
11 | data class SupabaseUser(
12 | val id: UUID,
13 | val email: String?,
14 | val phone: String?,
15 | val isAnonymous: Boolean,
16 | val userMetadata: MutableMap,
17 | val roles: List,
18 | val provider: String?,
19 | val verifiedJwt: String
20 | ) {
21 | companion object {
22 |
23 | fun createFromJWT(verifiedJwt: SupabaseJwtVerifier.VerificationResult): SupabaseUser {
24 | val claimsMap = verifiedJwt.claims
25 | val metadata = claimsMap["user_metadata"]?.asMap()?.toMutableMap() ?: mutableMapOf()
26 | return SupabaseUser(
27 | id = claimsMap["sub"]?.let {
28 | UUID.fromString(it.asString())
29 | } ?: throw ClaimsCannotBeNullException("sub claim is null"),
30 | email = claimsMap["email"]?.asString(),
31 | phone = claimsMap["phone"]?.asString(),
32 | isAnonymous = claimsMap["is_anonymous"]?.asBoolean() ?: true,
33 | userMetadata = metadata,
34 | roles = getRolesFromAppMetadata(claimsMap),
35 | provider = getProviderFromAppMetadata(claimsMap),
36 | verifiedJwt = verifiedJwt.token
37 | )
38 | }
39 |
40 | private fun getRolesFromAppMetadata(claimsMap: Map): List {
41 | val roles = claimsMap["app_metadata"]?.asMap()?.get("roles")
42 | if (roles is List<*> && roles.firstOrNull() is String) {
43 | return roles as List
44 | }
45 | return listOf()
46 | }
47 |
48 | private fun getProviderFromAppMetadata(claimsMap: Map): String {
49 | return claimsMap["app_metadata"]?.asMap()?.get("provider").toString() ?: ""
50 | }
51 | }
52 |
53 | fun getAuthorities(): MutableList? {
54 | val roleList = this.roles.map { "ROLE_${it.uppercase()}" }.toTypedArray()
55 | return AuthorityUtils.createAuthorityList(*roleList)
56 | }
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/spring-configuration-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "groups": [
3 | {
4 | "name": "supabase",
5 | "type": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties",
6 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
7 | }
8 | ],
9 | "properties": [
10 | {
11 | "name": "supabase.projectId",
12 | "type": "java.lang.String",
13 | "description": "The project id of your supabase project you can find that as Reference ID in your project settings or in the url https://app.supabase.com/project/$projectId",
14 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
15 | },
16 | {
17 | "name": "supabase.url",
18 | "type": "java.lang.String",
19 | "description": "The API-URL of your supabase instance (leave empty to use default: https://$projectId.supabase.co)",
20 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
21 | },
22 | {
23 | "name": "supabase.anonKey",
24 | "type": "java.lang.String",
25 | "description": "The anon project api key, found at Project settings - API",
26 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
27 | },
28 | {
29 | "name": "supabase.successfulLoginRedirectPage",
30 | "type": "java.lang.String",
31 | "description": "The Path where you want to redirect your User after successfully login in",
32 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
33 | },
34 | {
35 | "name": "supabase.passwordRecoveryPage",
36 | "type": "java.lang.String",
37 | "description": "The Path where a User gets redirected to change their password",
38 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
39 | },
40 | {
41 | "name": "supabase.sslOnly",
42 | "type": "java.lang.String",
43 | "description": "Sets the HTTP Cookie as Secure; Set False for local development. Set True for Production.",
44 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
45 | },
46 | {
47 | "name": "supabase.database",
48 | "type": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties.Database",
49 | "description": "You can configure the database properties here",
50 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
51 | },
52 | {
53 | "name": "supabase.database.host",
54 | "type": "java.lang.String",
55 | "description": "Database host, for example: aws-0-eu-central-1.pooler.supabase.com",
56 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
57 | },
58 | {
59 | "name": "supabase.database.name",
60 | "type": "java.lang.String",
61 | "description": "Database name, default: postgres",
62 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
63 | },
64 | {
65 | "name": "supabase.database.username",
66 | "type": "java.lang.String",
67 | "description": "username for database, defaults to postgres.${projectId}",
68 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
69 | },
70 | {
71 | "name": "supabase.database.password",
72 | "type": "java.lang.String",
73 | "description": "Database password",
74 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
75 | },
76 | {
77 | "name": "supabase.database.port",
78 | "type": "java.lang.Integer",
79 | "description": "Database port, defaults to 5432",
80 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
81 | },
82 | {
83 | "name": "supabase.public",
84 | "type": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties.Public",
85 | "description": "You can list here all public paths under the respective HTTP Methods",
86 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
87 | },
88 | {
89 | "name": "supabase.public",
90 | "type": "java.lang.Array",
91 | "description": "You can list here all public paths under the respective HTTP Methods",
92 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
93 | },
94 | {
95 | "name": "supabase.public.get",
96 | "type": "java.lang.Array",
97 | "description": "You can list here all public paths that should be accessible by GET HTTP Method",
98 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
99 | },
100 | {
101 | "name": "supabase.public.post",
102 | "type": "java.lang.Array",
103 | "description": "You can list here all public paths that should be accessible by POST HTTP Method",
104 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
105 | },
106 | {
107 | "name": "supabase.public.delete",
108 | "type": "java.lang.Array",
109 | "description": "You can list here all public paths that should be accessible by DELETE HTTP Method",
110 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
111 | },
112 | {
113 | "name": "supabase.public.put",
114 | "type": "java.lang.Array",
115 | "description": "You can list here all public paths that should be accessible by PUT HTTP Method",
116 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
117 | },
118 | {
119 | "name": "supabase.jwtSecret",
120 | "type": "java.lang.String",
121 | "description": "The JWT secret, found at Project settings - API ",
122 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
123 | },
124 | {
125 | "name": "supabase.unauthenticatedPage",
126 | "type": "java.lang.String",
127 | "description": "The Page the User will get redirected to when he is not logged in and tries to access a non public resource",
128 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
129 | },
130 | {
131 | "name": "supabase.unauthorizedPage",
132 | "type": "java.lang.String",
133 | "description": "The Page the User will get redirected to when a logged in User doesn't have the right to access a resource",
134 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
135 | },
136 | {
137 | "name": "supabase.otpCreateUser",
138 | "type": "java.lang.Boolean",
139 | "description": "When a new user requests a OTP should a user be created? If set to false this will create a ",
140 | "sourceType": "de.tschuehly.htmx.spring.supabase.auth.config.SupabaseProperties"
141 | }
142 | ],
143 | "hints": []
144 | }
145 |
--------------------------------------------------------------------------------
/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
--------------------------------------------------------------------------------
1 | de.tschuehly.htmx.spring.supabase.auth.SupabaseAutoConfiguration
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/application/CustomExceptionHandlerExample.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.application
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.exception.*
4 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.OtpEmailSent
5 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.PasswordRecoveryEmailSent
6 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.RegistrationConfirmationEmailSent
7 | import de.tschuehly.htmx.spring.supabase.auth.exception.email.SuccessfulPasswordUpdate
8 | import de.tschuehly.htmx.spring.supabase.auth.exception.handler.SupabaseExceptionHandler
9 | import de.tschuehly.htmx.spring.supabase.auth.exception.info.*
10 | import de.tschuehly.htmx.spring.supabase.auth.exception.info.MissingCredentialsException.Companion.MissingCredentials
11 | import org.springframework.web.bind.annotation.ControllerAdvice
12 | import org.springframework.web.bind.annotation.ResponseBody
13 |
14 | @ControllerAdvice
15 | class CustomExceptionHandlerExample : SupabaseExceptionHandler {
16 | @ResponseBody
17 | override fun handleMissingCredentialsException(exception: MissingCredentialsException): Any {
18 | return when (exception.message) {
19 | MissingCredentials.EMAIL_MISSING.message -> "You need to supply an email"
20 | MissingCredentials.PASSWORD_MISSING.message -> "You need to supply an password"
21 | MissingCredentials.PASSWORD_AND_EMAIL_MISSING.message -> "You need to supply both password and email"
22 | else -> "MissingCredentialsException"
23 | }
24 | }
25 |
26 |
27 | @ResponseBody
28 | override fun handleInvalidLoginCredentialsException(exception: InvalidLoginCredentialsException): Any {
29 | return "InvalidLoginCredentialsException"
30 | }
31 |
32 | @ResponseBody
33 | override fun handleUserNeedsToConfirmEmailForEmailChange(exception: UserNeedsToConfirmEmailForEmailChangeException): Any {
34 | return "UserNeedsToConfirmEmailForEmailChangeException"
35 | }
36 |
37 | @ResponseBody
38 | override fun handleUserNeedsToConfirmEmailBeforeLogin(exception: UserNeedsToConfirmEmailBeforeLoginException): Any {
39 | return "UserNeedsToConfirmEmailBeforeLoginException"
40 | }
41 |
42 | @ResponseBody
43 | override fun handleSuccessfulRegistration(exception: RegistrationConfirmationEmailSent): Any {
44 | return "SuccessfulRegistrationConfirmationEmailSent"
45 | }
46 |
47 | @ResponseBody
48 | override fun handlePasswordRecoveryEmailSent(exception: PasswordRecoveryEmailSent): Any {
49 | return "PasswordRecoveryEmailSent"
50 | }
51 |
52 | @ResponseBody
53 | override fun handleSuccessfulPasswordUpdate(exception: SuccessfulPasswordUpdate): Any {
54 | return "SuccessfulPasswordUpdate"
55 | }
56 |
57 | @ResponseBody
58 | override fun handleOtpEmailSent(exception: OtpEmailSent): Any {
59 | return "OtpEmailSent"
60 | }
61 |
62 | @ResponseBody
63 | override fun handleUserAlreadyRegisteredException(exception: UserAlreadyRegisteredException): Any {
64 | return "UserAlreadyRegisteredException"
65 | }
66 |
67 | @ResponseBody
68 | override fun handleWeakPasswordException(exception: WeakPasswordException): Any {
69 | return "WeakPasswordException"
70 | }
71 |
72 | @ResponseBody
73 | override fun handlePasswordChangeError(exception: NewPasswordShouldBeDifferentFromOldPasswordException): Any {
74 | return "NewPasswordShouldBeDifferentFromOldPasswordException"
75 | }
76 |
77 | @ResponseBody
78 | override fun handleMissingServiceRoleForAdminAccessException(exception: MissingServiceRoleForAdminAccessException): Any {
79 | return "MissingServiceRoleForAdminAccessException"
80 | }
81 |
82 | @ResponseBody
83 | override fun handleSupabaseAuthException(exception: SupabaseAuthException): Any {
84 | return exception.error
85 | }
86 |
87 | @ResponseBody
88 | override fun handleUnknownSupabaseException(exception: UnknownSupabaseException): Any {
89 | return "UnknownSupabaseException"
90 | }
91 |
92 | override fun handleOtpExpiredException(exception: OtpExpiredException): Any {
93 | return "OtpExpiredException"
94 | }
95 |
96 | override fun handleValidationFailedException(exception: ValidationFailedException): Any {
97 | return "ValidationFailedException"
98 | }
99 |
100 |
101 | }
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/application/ExampleWebController.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.application
2 |
3 | import org.springframework.http.HttpStatus
4 | import org.springframework.stereotype.Controller
5 | import org.springframework.web.bind.annotation.GetMapping
6 | import org.springframework.web.servlet.ModelAndView
7 |
8 |
9 | @Controller
10 | class ExampleWebController(
11 | ) {
12 |
13 | @GetMapping("/")
14 | fun index() = "index"
15 |
16 | @GetMapping("/admin")
17 | fun admin() = "admin"
18 |
19 | @GetMapping("/account")
20 | fun account(): String {
21 | return "account"
22 | }
23 |
24 | @GetMapping("/unauthenticated")
25 | fun unauthenticated(): ModelAndView {
26 | return ModelAndView("/unauthenticated", HttpStatus.FORBIDDEN)
27 | }
28 |
29 | @GetMapping("/unauthorized")
30 | fun unauthorized(): ModelAndView {
31 | return ModelAndView("/unauthorized", HttpStatus.FORBIDDEN)
32 | }
33 |
34 | @GetMapping("/updatePassword")
35 | fun updatePassword(): String {
36 | return "updatePassword"
37 | }
38 |
39 | @GetMapping("/requestPasswordReset")
40 | fun requestPasswordReset(): String {
41 | return "requestPasswordReset"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/application/TestApplication.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.application
2 |
3 | import org.springframework.boot.autoconfigure.SpringBootApplication
4 | import org.springframework.boot.runApplication
5 | import org.springframework.context.annotation.PropertySource
6 |
7 | @SpringBootApplication
8 | @PropertySource(value = ["classpath:/test.properties"], ignoreResourceNotFound = true)
9 | class TestApplication {
10 |
11 | }
12 |
13 | fun main(args: Array) {
14 | runApplication(*args)
15 | }
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/test/SupabaseGoTrueTest.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.test
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.application.TestApplication
4 | import de.tschuehly.htmx.spring.supabase.auth.security.JwtAuthenticationToken
5 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationProvider
6 | import de.tschuehly.htmx.spring.supabase.auth.security.SupabaseAuthenticationToken
7 | import de.tschuehly.htmx.spring.supabase.auth.test.mock.GoTrueMock
8 | import de.tschuehly.htmx.spring.supabase.auth.test.mock.GoTrueMockConfiguration
9 | import de.tschuehly.htmx.spring.supabase.auth.types.SupabaseUser
10 | import org.assertj.core.api.BDDAssertions.assertThatExceptionOfType
11 | import org.assertj.core.api.BDDAssertions.then
12 | import org.junit.jupiter.api.BeforeEach
13 | import org.junit.jupiter.api.Disabled
14 | import org.junit.jupiter.api.Test
15 | import org.junit.jupiter.api.extension.ExtendWith
16 | import org.mockito.ArgumentMatchers.any
17 | import org.mockito.Mockito
18 | import org.springframework.boot.test.context.SpringBootTest
19 | import org.springframework.boot.test.mock.mockito.MockBean
20 | import org.springframework.boot.test.web.client.TestRestTemplate
21 | import org.springframework.boot.test.web.client.getForEntity
22 | import org.springframework.boot.test.web.server.LocalServerPort
23 | import org.springframework.context.annotation.Import
24 | import org.springframework.http.HttpStatus
25 | import org.springframework.http.MediaType
26 | import org.springframework.http.ResponseEntity
27 | import org.springframework.test.context.TestPropertySource
28 | import org.springframework.test.context.junit.jupiter.SpringExtension
29 | import org.springframework.util.LinkedMultiValueMap
30 | import org.springframework.util.StringUtils
31 | import org.springframework.web.client.HttpClientErrorException
32 | import org.springframework.web.client.RestClient
33 | import org.springframework.web.client.toEntity
34 | import org.springframework.web.util.DefaultUriBuilderFactory
35 | import java.util.*
36 |
37 | @ExtendWith(SpringExtension::class)
38 | @SpringBootTest(
39 | classes = [TestApplication::class],
40 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
41 | properties = ["debug=org.springframework.security"],
42 | )
43 | @TestPropertySource(
44 | properties = ["SUPABASE_PROJECT_ID=", "SUPABASE_ANON_KEY=", "SUPABASE_DATABASE_PW=", "SUPABASE_JWT_SECRET="]
45 | )
46 | @Import(GoTrueMockConfiguration::class)
47 | class SupabaseGoTrueTest {
48 | lateinit var restClient: RestClient
49 | lateinit var restTemplate: TestRestTemplate
50 |
51 | @LocalServerPort
52 | var port: Int? = null
53 |
54 | @MockBean
55 | lateinit var authProvider: SupabaseAuthenticationProvider
56 |
57 | @BeforeEach
58 | fun setup() {
59 | restClient = RestClient.builder().baseUrl("http://localhost:$port").build()
60 | restTemplate = TestRestTemplate()
61 | restTemplate.setUriTemplateHandler(DefaultUriBuilderFactory("http://localhost:$port"))
62 | }
63 |
64 | @Test
65 | fun `User should be able to login`() {
66 | Mockito.`when`(authProvider.authenticate(JwtAuthenticationToken(GoTrueMock.NEW_ACCESS_TOKEN)))
67 | .thenReturn(
68 | SupabaseAuthenticationToken(
69 | SupabaseUser(
70 | UUID.randomUUID(),
71 | "email@example.com",
72 | null,
73 | false,
74 | mutableMapOf(),
75 | listOf("user"),
76 | "email",
77 | GoTrueMock.NEW_ACCESS_TOKEN
78 |
79 | )
80 | )
81 | )
82 | restClient.post().uri("/api/user/login")
83 | .form("email" to "email@example.com", "password" to GoTrueMock.VALID_PASSWORD)
84 | .retrieve()
85 | .toBodilessEntity()
86 | .let {
87 | then(it.statusCode).isEqualTo(HttpStatus.OK)
88 | then(it.headers["Set-Cookie"]?.get(0)).startsWith("JWT=new_access_token; Max-Age=6000; Expires=")
89 | .endsWith(" GMT; Path=/; HttpOnly")
90 | }
91 | }
92 |
93 | @Test
94 | fun `User should be able to signup with Email`() {
95 | restClient.post()
96 | .uri("/api/user/signup")
97 | .form("email" to "email@example.com", "password" to GoTrueMock.VALID_PASSWORD)
98 | .asString()
99 | .let {
100 | then(it.statusCode).isEqualTo(HttpStatus.OK)
101 | then(it.body).isEqualTo("SuccessfulRegistrationConfirmationEmailSent")
102 | }
103 | }
104 |
105 | @Test
106 | fun `Unauthorized User cannot access account site`() {
107 | assertThatExceptionOfType(HttpClientErrorException::class.java).isThrownBy {
108 | restClient.get().uri("/account").retrieve().toBodilessEntity()
109 | }.withMessage("403 : \"You need to sign in to access this side.\"")
110 |
111 | }
112 |
113 | @Test
114 | fun `Unauthorized User will be redirect to unauthenticated`() {
115 | restTemplate.getForEntity("/account").let {
116 | then(it.statusCode).isEqualTo(HttpStatus.FOUND)
117 | then(it.headers["Location"]?.get(0)).endsWith("/unauthenticated")
118 |
119 | }
120 | }
121 |
122 | @Test
123 | fun `User will be redirect to unauthenticated`() {
124 | restTemplate.getForEntity("/account").let {
125 | then(it.statusCode).isEqualTo(HttpStatus.FOUND)
126 | then(it.headers["Location"]?.get(0)).endsWith("/unauthenticated")
127 |
128 | }
129 | }
130 |
131 | @Test
132 | fun `Unauthorized User can access public site`() {
133 | restTemplate.getForEntity("/").let {
134 | then(it.statusCode).isEqualTo(HttpStatus.OK)
135 | }
136 | }
137 |
138 | @Test
139 | @Disabled
140 | fun `User can login and access the account page`() {
141 | // TODO: how to mock
142 | Mockito.`when`(authProvider.authenticate(any()))
143 | .thenReturn(
144 | SupabaseAuthenticationToken(
145 | SupabaseUser(
146 | UUID.randomUUID(),
147 | "email@example.com",
148 | null,
149 | false,
150 | mutableMapOf(),
151 | listOf("user"),
152 | "email",
153 | GoTrueMock.NEW_ACCESS_TOKEN
154 |
155 | )
156 | )
157 | )
158 | restClient.post().uri("/api/user/login")
159 | .form("email" to "email@example.com", "password" to GoTrueMock.VALID_PASSWORD)
160 | .retrieve().toBodilessEntity()
161 | restClient.get().uri("/account").retrieve().toBodilessEntity()
162 | }
163 |
164 | private fun RestClient.RequestBodySpec.asString(): ResponseEntity {
165 | return this.retrieve().toEntity()
166 | }
167 |
168 | private fun RestClient.RequestBodySpec.form(
169 | vararg formdata: Pair, jwt: String? = null
170 | ): RestClient.RequestBodySpec {
171 | return this.contentType(MediaType.APPLICATION_FORM_URLENCODED).addJWTCookie(jwt).body(
172 | LinkedMultiValueMap(formdata.groupBy({ it.first }, { it.second }))
173 | )
174 | }
175 |
176 | private fun RestClient.RequestBodySpec.addJWTCookie(jwt: String?): RestClient.RequestBodySpec {
177 | jwt?.let {
178 | this.headers {
179 | it.add("Cookie", "JWT=" + StringUtils.trimAllWhitespace(jwt))
180 | }
181 | }
182 | return this
183 | }
184 | }
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/test/SupabaseHtmxTests.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.test
2 |
3 | import de.tschuehly.htmx.spring.supabase.auth.application.TestApplication
4 | import de.tschuehly.htmx.spring.supabase.auth.test.mock.GoTrueMockConfiguration
5 | import org.assertj.core.api.BDDAssertions.then
6 | import org.htmlunit.SilentCssErrorHandler
7 | import org.htmlunit.WebClient
8 | import org.htmlunit.html.*
9 | import org.htmlunit.javascript.SilentJavaScriptErrorListener
10 | import org.junit.jupiter.api.BeforeEach
11 | import org.junit.jupiter.api.Test
12 | import org.junit.jupiter.api.extension.ExtendWith
13 | import org.springframework.boot.test.context.SpringBootTest
14 | import org.springframework.boot.test.web.server.LocalServerPort
15 | import org.springframework.context.annotation.Import
16 | import org.springframework.context.annotation.PropertySource
17 | import org.springframework.test.context.TestPropertySource
18 | import org.springframework.test.context.junit.jupiter.SpringExtension
19 | import org.springframework.web.context.WebApplicationContext
20 |
21 | @ExtendWith(SpringExtension::class)
22 | @SpringBootTest(
23 | classes = [TestApplication::class],
24 | webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
25 | properties = ["debug=org.springframework.security"],
26 | )
27 | @PropertySource(value = ["classpath:/test.properties"], ignoreResourceNotFound = true)
28 | @Import(GoTrueMockConfiguration::class)
29 | class SupabaseHtmxTests {
30 |
31 | @LocalServerPort
32 | var port: Int? = null
33 | val webClient: WebClient = WebClient()
34 |
35 | @BeforeEach
36 | fun setup(context: WebApplicationContext?) {
37 | webClient.options.isThrowExceptionOnScriptError = false;
38 | webClient.setCssErrorHandler(SilentCssErrorHandler())
39 | webClient.javaScriptErrorListener = SilentJavaScriptErrorListener()
40 | }
41 |
42 | @Test
43 | fun invalidLoginCredentialsThrowExceptionWhenLogin() {
44 | val page: HtmlPage = webClient.getPage("http://localhost:$port/")
45 | val form = page.getFormByName("login-form")
46 | val emailInput: HtmlTextInput = form.getInputByName("email")
47 | emailInput.type("mail@example.com")
48 | val passwordInput: HtmlPasswordInput = form.getInputByName("password")
49 | passwordInput.type("Test1234")
50 | val pageResult: HtmlPage = form.getButtonByName("submit").click()
51 | webClient.waitForBackgroundJavaScript(500)
52 | then(pageResult.getElementById("login-response").textContent).isEqualTo("UnknownSupabaseException")
53 | // TODO: Change mock to match supabase api? https://github.com/supabase-community/supabase-kt/issues/362
54 |
55 | passwordInput.type("password")
56 | val pageResult2: HtmlPage = form.getButtonByName("submit").click()
57 | webClient.waitForBackgroundJavaScript(500)
58 | then(pageResult2.getElementById("login-response").textContent).isEqualTo("UnknownSupabaseException")
59 |
60 | }
61 |
62 | }
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/test/mock/GoTrueMock.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.test.mock
2 |
3 | import io.github.jan.supabase.auth.user.UserInfo
4 | import io.github.jan.supabase.auth.user.UserSession
5 | import io.ktor.client.engine.mock.*
6 | import io.ktor.client.request.*
7 | import io.ktor.http.*
8 | import kotlinx.datetime.Clock
9 | import kotlinx.serialization.encodeToString
10 | import kotlinx.serialization.json.*
11 |
12 | class GoTrueMock {
13 |
14 | val engine = MockEngine {
15 | handleRequest(it) ?: respondInternalError("Invalid route")
16 | }
17 |
18 | private suspend fun MockRequestHandleScope.handleRequest(request: HttpRequestData): HttpResponseData? {
19 | val url = request.url
20 | val urlWithoutQuery = url.encodedPath
21 | return when {
22 | urlWithoutQuery.endsWith("token") -> handleLogin(request)
23 | urlWithoutQuery.endsWith("signup") -> handleSignUp(request)
24 | urlWithoutQuery.endsWith("user") -> handleUserRequest(request)
25 | urlWithoutQuery.endsWith("verify") -> handleVerify(request)
26 | urlWithoutQuery.endsWith("otp") -> handleOtp(request)
27 | urlWithoutQuery.endsWith("recover") -> handleRecovery(request)
28 | urlWithoutQuery.endsWith("logout") -> handleLogout(request)
29 | else -> null
30 | }
31 | }
32 |
33 | private fun MockRequestHandleScope.handleLogout(request: HttpRequestData): HttpResponseData {
34 | if (request.method != HttpMethod.Post) return respondBadRequest("Invalid method")
35 | if (!request.headers.contains("Authorization")) return respondBadRequest("access token missing")
36 | return respondOk()
37 | }
38 |
39 | private suspend fun MockRequestHandleScope.handleRecovery(request: HttpRequestData): HttpResponseData {
40 | if (request.method != HttpMethod.Post) respondBadRequest("Invalid method")
41 | val body = try {
42 | request.decodeJsonObject()
43 | } catch (e: Exception) {
44 | return respondBadRequest("Invalid body")
45 | }
46 | if (!body.containsKey("email")) return respondBadRequest("Email missing")
47 | return respondOk()
48 | }
49 |
50 | private suspend fun MockRequestHandleScope.handleOtp(request: HttpRequestData): HttpResponseData {
51 | if (request.method != HttpMethod.Post) respondBadRequest("Invalid method")
52 | val body = try {
53 | request.decodeJsonObject()
54 | } catch (e: Exception) {
55 | return respondBadRequest("Invalid body")
56 | }
57 | if (!body.containsKey("create_user")) return respondBadRequest("create_user missing")
58 | if (!body.containsKey("phone") && !body.containsKey("email")) return respondBadRequest("email or phone missing")
59 | return respondOk("{}")
60 | }
61 |
62 | private suspend fun MockRequestHandleScope.handleVerify(request: HttpRequestData): HttpResponseData {
63 | if (request.method != HttpMethod.Post) return respondBadRequest("Invalid method")
64 | val body = try {
65 | request.decodeJsonObject()
66 | } catch (e: Exception) {
67 | return respondBadRequest("Invalid body")
68 | }
69 | if (!body.containsKey("token")) return respondBadRequest("token missing")
70 | if (!body.containsKey("type")) return respondBadRequest("type missing")
71 | val token = body["token"]!!.jsonPrimitive.content
72 | if (token != VALID_VERIFY_TOKEN) return respondBadRequest("Failed to verify user")
73 | return when (body["type"]!!.jsonPrimitive.content) {
74 | in listOf("invite", "signup", "recovery") -> respondValidSession()
75 | "sms" -> {
76 | body["phone"]?.jsonPrimitive?.contentOrNull
77 | ?: return respondBadRequest("Missing parameter: phone_number")
78 | respondValidSession()
79 | }
80 |
81 | else -> respondBadRequest("Invalid type")
82 | }
83 | }
84 |
85 | private fun MockRequestHandleScope.handleUserRequest(request: HttpRequestData): HttpResponseData {
86 | if (!request.headers.contains(HttpHeaders.Authorization)) return respondUnauthorized()
87 | val authorizationHeader = request.headers[HttpHeaders.Authorization]!!
88 | if (!authorizationHeader.startsWith("Bearer ")) return respondUnauthorized()
89 | val token = authorizationHeader.substringAfter("Bearer ")
90 | if (token != VALID_ACCESS_TOKEN) return respondUnauthorized()
91 | return when (request.method) {
92 | HttpMethod.Get -> respondJson(UserInfo(aud = "", id = "userid"))
93 | HttpMethod.Put -> {
94 | respondJson(
95 | UserInfo(
96 | aud = "",
97 | id = "userid",
98 | email = "old_email@email.com",
99 | emailChangeSentAt = Clock.System.now()
100 | )
101 | )
102 | }
103 |
104 | else -> return respondBadRequest("Invalid method")
105 | }
106 | }
107 |
108 | private suspend fun MockRequestHandleScope.handleSignUp(request: HttpRequestData): HttpResponseData {
109 | if (request.method != HttpMethod.Post) respondBadRequest("Invalid method")
110 | val body = try {
111 | request.decodeJsonObject()
112 | } catch (e: Exception) {
113 | return respondBadRequest("Invalid body")
114 | }
115 |
116 | return when {
117 | body.containsKey("email") -> {
118 | respond(
119 | sampleUserObject(body["email"]!!.jsonPrimitive.content),
120 | HttpStatusCode.OK,
121 | headersOf("Content-Type" to listOf("application/json"))
122 |
123 | )
124 | }
125 |
126 | body.containsKey("phone") -> {
127 | respond(
128 | sampleUserObject(body["phone"]!!.jsonPrimitive.content),
129 | HttpStatusCode.OK,
130 | headersOf("Content-Type" to listOf("application/json"))
131 | )
132 | }
133 |
134 | !body.containsKey("password") -> respondBadRequest("Missing password")
135 | else -> respondBadRequest("Missing email or phone")
136 | }
137 | }
138 |
139 | private suspend fun MockRequestHandleScope.handleLogin(request: HttpRequestData): HttpResponseData {
140 | if (request.method != HttpMethod.Post) respondBadRequest("Invalid method")
141 | if (!request.url.parameters.contains("grant_type")) return respondBadRequest("grant_type is required")
142 | return when (request.url.parameters["grant_type"]) {
143 | "refresh_token" -> {
144 | val body = try {
145 | request.decodeJsonObject()
146 | } catch (e: Exception) {
147 | return respondBadRequest("Invalid body")
148 | }
149 | if (!body.containsKey("refresh_token")) return respondBadRequest("refresh_token is required")
150 | val refreshToken = body["refresh_token"]!!.jsonPrimitive.content
151 | if (refreshToken != VALID_REFRESH_TOKEN) return respondBadRequest("Invalid refresh token")
152 | respondValidSession()
153 | }
154 |
155 | "password" -> {
156 | val body = try {
157 | request.decodeJsonObject()
158 | } catch (e: Exception) {
159 | return respondBadRequest("Invalid body")
160 | }
161 | if (!body.containsKey("password")) return respondBadRequest("password is required")
162 | val password = body["password"]?.jsonPrimitive?.contentOrNull ?: ""
163 | return when {
164 | body.containsKey("email") -> {
165 | if (password != VALID_PASSWORD) return respondBadRequest("Invalid password")
166 | respondValidSession()
167 | }
168 |
169 | body.containsKey("phone") -> {
170 | if (password != VALID_PASSWORD) return respondBadRequest("Invalid password")
171 | respondValidSession()
172 | }
173 |
174 | else -> respondBadRequest("email or phone is required")
175 | }
176 | }
177 |
178 | else -> respondBadRequest("grant_type must be password")
179 | }
180 | }
181 |
182 | private inline fun MockRequestHandleScope.respondJson(data: T): HttpResponseData {
183 | return respond(
184 | Json.encodeToString(data),
185 | HttpStatusCode.OK,
186 | headersOf("Content-Type" to listOf("application/json"))
187 | )
188 | }
189 |
190 | private fun MockRequestHandleScope.respondValidSession() = respondJson(
191 | UserSession(
192 | NEW_ACCESS_TOKEN,
193 | "refresh_token",
194 | "",
195 | "",
196 | 200,
197 | "token_type",
198 | UserInfo(aud = "", id = "")
199 | )
200 | )
201 |
202 | private fun MockRequestHandleScope.respondInternalError(message: String): HttpResponseData {
203 | return respond(message, HttpStatusCode.InternalServerError)
204 | }
205 |
206 | private fun MockRequestHandleScope.respondBadRequest(message: String): HttpResponseData {
207 | return respond(buildJsonObject {
208 | put("error", message)
209 | }.toString(), HttpStatusCode.BadRequest)
210 | }
211 |
212 | private fun MockRequestHandleScope.respondUnauthorized(): HttpResponseData {
213 | return respond("Unauthorized", HttpStatusCode.Unauthorized)
214 | }
215 |
216 | private suspend inline fun HttpRequestData.decodeJsonObject() =
217 | Json.decodeFromString(body.toByteArray().decodeToString())
218 |
219 | companion object {
220 | const val VALID_PASSWORD = "password"
221 | const val VALID_REFRESH_TOKEN = "valid_refresh_token"
222 | const val NEW_ACCESS_TOKEN = "new_access_token"
223 | const val VALID_ACCESS_TOKEN = "valid_access_token"
224 | const val VALID_VERIFY_TOKEN = "valid_verify_token"
225 | }
226 |
227 | private fun sampleUserObject(email: String? = null, phone: String? = null) = """
228 | {
229 | "id": "id",
230 | "aud": "aud",
231 | "email": "$email",
232 | "phone": "$phone"
233 | }
234 | """.trimIndent()
235 |
236 | }
--------------------------------------------------------------------------------
/src/test/kotlin/de/tschuehly/htmx/spring/supabase/auth/test/mock/GoTrueMockConfiguration.kt:
--------------------------------------------------------------------------------
1 | package de.tschuehly.htmx.spring.supabase.auth.test.mock
2 |
3 | import io.github.jan.supabase.auth.Auth
4 | import io.github.jan.supabase.auth.auth
5 | import io.github.jan.supabase.createSupabaseClient
6 | import kotlinx.coroutines.test.StandardTestDispatcher
7 | import org.springframework.context.annotation.Bean
8 | import org.springframework.context.annotation.Configuration
9 |
10 | @Configuration
11 | class GoTrueMockConfiguration {
12 | val mockEngine = GoTrueMock().engine
13 |
14 | val dispatcher = StandardTestDispatcher()
15 |
16 |
17 | @Bean
18 | fun createSupabaseClient(): Auth {
19 | val supabase = createSupabaseClient(
20 | supabaseUrl = "https://example.com",
21 | supabaseKey = "example",
22 | ) {
23 | httpEngine = mockEngine
24 | install(Auth) {
25 | coroutineDispatcher = dispatcher
26 | autoSaveToStorage = false
27 | autoLoadFromStorage = false
28 | alwaysAutoRefresh = false
29 | }
30 | }
31 | return supabase.auth
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/resources/application.yaml:
--------------------------------------------------------------------------------
1 | supabase:
2 | successfulLoginRedirectPage: "/"
3 | passwordRecoveryPage: "/requestPasswordReset"
4 | unauthenticatedPage: "/unauthenticated"
5 | unauthorizedPage: "/unauthorized"
6 | sslOnly: false
7 | basicAuth:
8 | enabled: true
9 | username: admin
10 | password: "{bcrypt}$2a$10$AqgP120RLJ48mvTv.diNHeVlQA/WdsrgEr0aLe5P1ffYPy1FQAecy"
11 | roles:
12 | - "ADMIN"
13 | roles:
14 | admin:
15 | get:
16 | - "/admin/**"
17 | public:
18 | get:
19 | - "/"
20 | - "/jdbc"
21 | - "/favicon.ico"
22 | - "/logout"
23 | - "/login"
24 | - "/error"
25 | - "/unauthenticated"
26 | - "/unauthorized"
27 | - "/requestPasswordReset"
28 | - "/api/user/logout"
29 | post:
30 | - "/api/user/signup"
31 | - "/api/user/signInWithMagicLink"
32 | - "/api/user/login"
33 | - "/api/user/jwt"
34 | - "/api/user/sendPasswordResetEmail"
35 |
36 | logging:
37 | level:
38 | de.tschuehly: debug
39 | org.springframework.security: debug
40 | server:
41 | port: 8765
--------------------------------------------------------------------------------
/src/test/resources/fixtures/expired-user-jwt.txt:
--------------------------------------------------------------------------------
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjY1NzkxNjIzLCJzdWIiOiJmODAyYzNiYi0yMjNlLTQzYTYtYmJhMC01YWU2MDk0ZjBkOTEiLCJlbWFpbCI6InRob21hcy5zY2h1ZWhseUBvdXRsb29rLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsInNlc3Npb25faWQiOiIzNDdhMDk1Ny01ZDlmLTQ1ZjAtOWMwZi05YzY5MTUwNmFmNzEifQ.SN-jHx_XCJl70NDJe-8WRb7r65aDUPWqdnon61PI0eo
2 |
--------------------------------------------------------------------------------
/src/test/resources/fixtures/login-response.json:
--------------------------------------------------------------------------------
1 | {
2 | "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoiZjgwMmMzYmItMjIzZS00M2E2LWJiYTAtNWFlNjA5NGYwZDkxIiwiZW1haWwiOiJmaXJzdC5sYXN0QGV4YW1wbGUuY29tIiwiYXBwX21ldGFkYXRhIjoie1xuICAgICAgICAgICAgICAgICAgXCJwcm92aWRlclwiOiBcImVtYWlsXCIsXG4gICAgICAgICAgICAgICAgICBcInByb3ZpZGVyc1wiOiBbXG4gICAgICAgICAgICAgICAgICAgIFwiZW1haWxcIlxuICAgICAgICAgICAgICAgICAgXVxuICAgICAgICAgICAgICAgIH0ifQ.VRxN271KulPW6uKD9JUmr1pg7jC00LUTG2gUGgXIMdA",
3 | "token_type": "bearer",
4 | "expires_in": 3600,
5 | "refresh_token": "UsMf4YHXXduDA4tDjO1zZA",
6 | "user": {
7 | "id": "f802c3bb-223e-43a6-bba0-5ae6094f0d91",
8 | "aud": "authenticated",
9 | "role": "authenticated",
10 | "email": "thomas.schuehly@outlook.com",
11 | "email_confirmed_at": "2022-10-14T07:27:34.750707Z",
12 | "phone": "",
13 | "confirmation_sent_at": "2022-10-14T07:27:06.691377Z",
14 | "confirmed_at": "2022-10-14T07:27:34.750707Z",
15 | "last_sign_in_at": "2022-10-14T22:53:43.333484484Z",
16 | "app_metadata": {
17 | "provider": "email",
18 | "providers": [
19 | "email"
20 | ]
21 | },
22 | "user_metadata": {},
23 | "identities": [
24 | {
25 | "id": "f802c3bb-223e-43a6-bba0-5ae6094f0d91",
26 | "user_id": "f802c3bb-223e-43a6-bba0-5ae6094f0d91",
27 | "identity_data": {
28 | "sub": "f802c3bb-223e-43a6-bba0-5ae6094f0d91"
29 | },
30 | "provider": "email",
31 | "last_sign_in_at": "2022-10-14T07:27:06.689269Z",
32 | "created_at": "2022-10-14T07:27:06.689308Z",
33 | "updated_at": "2022-10-14T07:27:06.689311Z"
34 | }
35 | ],
36 | "created_at": "2022-10-14T07:27:06.684506Z",
37 | "updated_at": "2022-10-14T22:53:43.335165Z"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/test/resources/fixtures/service-role-user-jwt.txt:
--------------------------------------------------------------------------------
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjc2NzYyNjc3LCJzdWIiOiI1NGExMjYxOS1jZWUzLTQ4NTYtYTg1NC1iYWQxNGQ0NjM5ZWQiLCJlbWFpbCI6InRzY2h1ZWhseUBvdXRsb29rLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoic2VydmljZV9yb2xlIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE2NzY3NTkwNzd9XSwic2Vzc2lvbl9pZCI6IjgyZmE1MGZhLTgzNDgtNGU2MS1iYmY4LTRmMmNiOGJjZWUwMiJ9.2SzWnpsElhjH2GqiJ4jxe5pBPNIbIxjrH8Cb9Szu8hE
--------------------------------------------------------------------------------
/src/test/resources/fixtures/set-roles-response.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "54a12619-cee3-4856-a854-bad14d4639ed",
3 | "aud": "authenticated",
4 | "role": "service_role",
5 | "email": "tschuehly@outlook.com",
6 | "email_confirmed_at": "2023-02-18T15:35:08.347535Z",
7 | "phone": "",
8 | "confirmation_sent_at": "2023-02-18T15:33:53.106227Z",
9 | "confirmed_at": "2023-02-18T15:35:08.347535Z",
10 | "last_sign_in_at": "2023-02-18T22:24:37.187055Z",
11 | "app_metadata": {
12 | "provider": "email",
13 | "providers": [
14 | "email"
15 | ],
16 | "roles": [
17 | "user"
18 | ]
19 | },
20 | "user_metadata": {},
21 | "identities": [
22 | {
23 | "id": "54a12619-cee3-4856-a854-bad14d4639ed",
24 | "user_id": "54a12619-cee3-4856-a854-bad14d4639ed",
25 | "identity_data": {
26 | "email": "tschuehly@outlook.com",
27 | "sub": "54a12619-cee3-4856-a854-bad14d4639ed"
28 | },
29 | "provider": "email",
30 | "last_sign_in_at": "2023-02-18T15:33:53.102094Z",
31 | "created_at": "2023-02-18T15:33:53.102132Z",
32 | "updated_at": "2023-02-18T15:33:53.102132Z"
33 | }
34 | ],
35 | "created_at": "2023-02-18T15:33:53.094444Z",
36 | "updated_at": "2023-02-18T22:24:45.207207Z"
37 | }
--------------------------------------------------------------------------------
/src/test/resources/fixtures/settings-response.json:
--------------------------------------------------------------------------------
1 | {
2 | "external": {
3 | "bitbucket": false,
4 | "github": false,
5 | "gitlab": false,
6 | "google": false,
7 | "facebook": false,
8 | "email": true,
9 | "saml": false
10 | },
11 | "external_labels": {},
12 | "disable_signup": false,
13 | "autoconfirm": true
14 | }
--------------------------------------------------------------------------------
/src/test/resources/fixtures/signup-response.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "455bb335-8310-4abc-b247-b1f61b09272c",
3 | "aud": "authenticated",
4 | "role": "authenticated",
5 | "email": "foo@example.com",
6 | "phone": "",
7 | "confirmation_sent_at": "2022-10-14T22:13:49.155447226Z",
8 | "app_metadata": {
9 | "provider": "email",
10 | "providers": [
11 | "email"
12 | ]
13 | },
14 | "user_metadata": {},
15 | "identities": [
16 | {
17 | "id": "455bb335-8310-4abc-b247-b1f61b09272c",
18 | "user_id": "455bb335-8310-4abc-b247-b1f61b09272c",
19 | "identity_data": {
20 | "sub": "455bb335-8310-4abc-b247-b1f61b09272c"
21 | },
22 | "provider": "email",
23 | "last_sign_in_at": "2022-10-14T22:13:49.153935441Z",
24 | "created_at": "2022-10-14T22:13:49.153972Z",
25 | "updated_at": "2022-10-14T22:13:49.153975Z"
26 | }
27 | ],
28 | "created_at": "2022-10-14T22:13:49.150059Z",
29 | "updated_at": "2022-10-14T22:13:49.520685Z"
30 | }
31 |
--------------------------------------------------------------------------------
/src/test/resources/fixtures/user-response-email-disabled.json:
--------------------------------------------------------------------------------
1 | {
2 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
3 | "token_type": "bearer",
4 | "expires_in": 3600,
5 | "refresh_token": "nGAyjPONelHS6XG-asAsnQ",
6 | "user": {
7 | "id": "b04343d0-8164-4c92-9aaa-cdbbb9188358",
8 | "aud": "authenticated",
9 | "role": "authenticated",
10 | "email": "foo@bar.com",
11 | "email_confirmed_at": "2022-02-09T10:36:38.750745355Z",
12 | "phone": "",
13 | "confirmation_sent_at": "2022-02-09T10:34:22.105331Z",
14 | "last_sign_in_at": "2022-02-09T10:36:38.754228834Z",
15 | "app_metadata": {
16 | "provider": "email",
17 | "providers": [
18 | "email"
19 | ]
20 | },
21 | "user_metadata": {},
22 | "identities": [
23 | {
24 | "id": "b04343d0-8164-4c92-9aaa-cdbbb9188358",
25 | "user_id": "b04343d0-8164-4c92-9aaa-cdbbb9188358",
26 | "identity_data": {
27 | "sub": "b04343d0-8164-4c92-9aaa-cdbbb9188358"
28 | },
29 | "provider": "email",
30 | "last_sign_in_at": "2022-02-08T22:25:10.211158Z",
31 | "created_at": "2022-02-08T22:25:10.211215Z",
32 | "updated_at": "2022-02-08T22:25:10.211219Z"
33 | }
34 | ],
35 | "created_at": "2022-02-08T22:25:10.207882Z",
36 | "updated_at": "2022-02-09T10:36:38.756078Z"
37 | }
38 | }
--------------------------------------------------------------------------------
/src/test/resources/fixtures/valid-user-jwt.txt:
--------------------------------------------------------------------------------
1 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwic3ViIjoiZjgwMmMzYmItMjIzZS00M2E2LWJiYTAtNWFlNjA5NGYwZDkxIiwiZW1haWwiOiJmaXJzdC5sYXN0QGV4YW1wbGUuY29tIiwiYXBwX21ldGFkYXRhIjoie1xuICAgICAgICAgICAgICAgICAgXCJwcm92aWRlclwiOiBcImVtYWlsXCIsXG4gICAgICAgICAgICAgICAgICBcInByb3ZpZGVyc1wiOiBbXG4gICAgICAgICAgICAgICAgICAgIFwiZW1haWxcIlxuICAgICAgICAgICAgICAgICAgXVxuICAgICAgICAgICAgICAgIH0ifQ.VRxN271KulPW6uKD9JUmr1pg7jC00LUTG2gUGgXIMdA
--------------------------------------------------------------------------------
/src/test/resources/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tschuehly/htmx-supabase-spring-boot-starter/be2bc84aff3e4a5c88941b36d6ba3b2a97fab2bd/src/test/resources/static/favicon.ico
--------------------------------------------------------------------------------
/src/test/resources/template.env:
--------------------------------------------------------------------------------
1 | SUPABASE_PROJECT_ID=
2 | SUPABASE_ANON_KEY=
3 | SUPABASE_DATABASE_PW=
4 | SUPABASE_JWT_SECRET=
--------------------------------------------------------------------------------
/src/test/resources/templates/403.html:
--------------------------------------------------------------------------------
1 |
2 | Access Denied
3 |
4 |
--------------------------------------------------------------------------------
/src/test/resources/templates/account.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
7 |
8 |
9 |
10 | Logged user:
11 | You are authenticated
12 | Update Password
13 |
14 |
15 |
You are an admin:
16 |
Admin Page
17 |
18 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/test/resources/templates/admin.html:
--------------------------------------------------------------------------------
1 |
2 | You are a Admin!
3 |
--------------------------------------------------------------------------------
/src/test/resources/templates/error.html:
--------------------------------------------------------------------------------
1 | Error occurred
--------------------------------------------------------------------------------
/src/test/resources/templates/index.html:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 | Supabase Auth
7 |
8 |
9 |
10 |
11 | Supabase Security Spring Boot Starter
12 |
13 |
14 |
15 | authentication
16 |
17 |
18 | authorization
19 |
20 |
21 |
22 |
23 | Login
24 |
25 |
34 |
35 |
36 |
37 | Sign Up
38 |
39 |
48 |
49 |
50 | OTP
51 |
52 |
58 |
59 |
60 |
61 |
Sign In with Google
62 |
63 |
64 |
65 |
66 |
72 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/src/test/resources/templates/requestPasswordReset.html:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 | Title
7 |
8 |
9 |
10 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/test/resources/templates/resetPassword.html:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/test/resources/templates/scripts.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/test/resources/templates/unauthenticated.html:
--------------------------------------------------------------------------------
1 | You need to sign in to access this side.
--------------------------------------------------------------------------------
/src/test/resources/templates/unauthorized.html:
--------------------------------------------------------------------------------
1 | You don't have permission to view this site
--------------------------------------------------------------------------------
/src/test/resources/templates/updatePassword.html:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/supabase/.gitignore:
--------------------------------------------------------------------------------
1 | # Supabase
2 | .branches
3 | .temp
4 | /backup-**
5 |
--------------------------------------------------------------------------------
/supabase/config.toml:
--------------------------------------------------------------------------------
1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the
2 | # working directory name when running `supabase init`.
3 | project_id = "htmx-supabase-spring-boot"
4 |
5 | [api]
6 | # Port to use for the API URL.
7 | port = 64321
8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
9 | # endpoints. public and storage are always included.
10 | schemas = ["public", "storage", "graphql_public"]
11 | # Extra schemas to add to the search_path of every request. public is always included.
12 | extra_search_path = ["public", "extensions"]
13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
14 | # for accidental or malicious requests.
15 | max_rows = 1000
16 |
17 | [db]
18 | # Port to use for the local database URL.
19 | port = 64322
20 | # Port used by db diff command to initialise the shadow database.
21 | shadow_port = 54320
22 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW
23 | # server_version;` on the remote database to check.
24 | major_version = 15
25 |
26 | [studio]
27 | enabled = true
28 | # Port to use for Supabase Studio.
29 | port = 64323
30 | # External URL of the API server that frontend connects to.
31 | api_url = "http://localhost"
32 |
33 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
34 | # are monitored, and you can view the emails that would have been sent from the web interface.
35 | [inbucket]
36 | enabled = true
37 | # Port to use for the email testing server web interface.
38 | port = 64324
39 | # Uncomment to expose additional ports for testing user applications that send emails.
40 | # smtp_port = 54325
41 | # pop3_port = 54326
42 |
43 | [storage]
44 | # The maximum file size allowed (e.g. "5MB", "500KB").
45 | file_size_limit = "50MiB"
46 |
47 | [auth]
48 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
49 | # in emails.
50 | site_url = "http://localhost:8765"
51 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
52 | additional_redirect_urls = ["http://localhost:8765"]
53 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
54 | jwt_expiry = 3600
55 | # If disabled, the refresh token will never expire.
56 | enable_refresh_token_rotation = true
57 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
58 | # Requires enable_refresh_token_rotation = true.
59 | refresh_token_reuse_interval = 10
60 | # Allow/disallow new user signups to your project.
61 | enable_signup = true
62 | enable_anonymous_sign_ins = true
63 |
64 | [auth.email]
65 | # Allow/disallow new user signups via email to your project.
66 | enable_signup = true
67 | # If enabled, a user will be required to confirm any email change on both the old, and new email
68 | # addresses. If disabled, only the new email is required to confirm.
69 | double_confirm_changes = true
70 | # If enabled, users need to confirm their email address before signing in.
71 | enable_confirmations = true
72 |
73 | [auth.sms]
74 | # Allow/disallow new user signups via SMS to your project.
75 | enable_signup = true
76 | # If enabled, users need to confirm their phone number before signing in.
77 | enable_confirmations = false
78 |
79 | # Configure one of the supported SMS providers: `twilio`, `messagebird`, `textlocal`, `vonage`.
80 | [auth.sms.twilio]
81 | enabled = false
82 | account_sid = ""
83 | message_service_sid = ""
84 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
85 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
86 |
87 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
88 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`,
89 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`.
90 | [auth.external.apple]
91 | enabled = false
92 | client_id = ""
93 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
94 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
95 | # Overrides the default auth redirectUrl.
96 | redirect_uri = ""
97 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
98 | # or any other third-party OIDC providers.
99 | url = ""
100 |
101 | [analytics]
102 | enabled = false
103 | port = 54327
104 | vector_port = 54328
105 | # Setup BigQuery project to enable log viewer on local development stack.
106 | # See: https://supabase.com/docs/guides/getting-started/local-development#enabling-local-logging
107 | gcp_project_id = ""
108 | gcp_project_number = ""
109 | gcp_jwt_path = "supabase/gcloud.json"
110 |
--------------------------------------------------------------------------------