This Week I Learned: OAuth2 SSO with Spring Security in Kotlin… on one node [2023–04–14]
I’m writing an internal productivity tool in which users can change shared data in a database, so therefore I want to authenticate and identify them. It should also be a painless process for users: quick and with no added steps.
Getting there wasn’t straightforward and the details will be useful for other internal services, so I’m going to note them down here, how-to-guide style.
We have SSO via OneLogin, and Kotlin microservices running Spring Boot are on our golden path, so I used them as the path of least resistance.
(This will work with other SSO providers such as Okta, Google, Microsoft, Facebook etc. Just substitute their name and URLs into the config below.)
The result: when an unauthenticated user visits my service they are bounced to OneLogin, they’re usually already logged in there but if not then they log in, and then they’re returned to the URL in my service that they wanted. If they refresh then they are not bounced through OneLogin (until their authentication expires some hours later). Nice!
Setup: Hello world with Spring Boot
I’m going to assume that you’ve already got your web app serving requests. E.g. http://localhost:9000/hello
displays text hello world
.
Getting that going is outside the scope of this how-to guide, but here are some dependencies that I’m using:
val springBootVersion = "2.7.9"
val springBootDependencies = "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
implementation(platform(springBootDependencies))
kapt(platform(springBootDependencies))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
Add Spring Security
Add some new dependencies:
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.6.8")
implementation("org.springframework.security:spring-security-config")
implementation("org.springframework.security:spring-security-oauth2-client")
implementation("org.springframework.security:spring-security-oauth2-jose")
Add some config to your application.yaml
:
spring:
security:
oauth2:
client:
registration:
onelogin:
client-id: "your-client-id"
client-secret: ${ONELOGIN_CLIENT_SECRET} # set your env var securely
scope:
- openid
- profile
- email
redirect-uri: https://localhost:9000/login/oauth2/code/onelogin # different in prod
provider:
onelogin:
user-info-uri: https://your-subdomain.onelogin.com/oidc/2/me
token-uri: https://your-subdomain.onelogin.com/oidc/2/token
authorization-uri: https://your-subdomain.onelogin.com/oidc/2/auth
issuer-uri: https://your-subdomain.onelogin.com/oidc/2
jwk-set-uri: https://your-subdomain.onelogin.com/oidc/2/certs
Spring Security will now intercept requests and try to make every browser user authenticate. Try browsing to http://localhost:9000/hello
and it should ask you to log in somewhere but not ever get you back to that URL showing hello world
. That won’t work until you…
Configure HttpSecurity
Spring won’t know what type of login you want to enforce nor what paths to leave unauthenticated (and there will be some — e.g. health-checks).
Tell it this info by creating a class that produces a bean of the right type somewhere under src/main/kotlin
:
@Configuration
@EnableWebSecurity
@Order(98)
class SecurityConfiguration {
@Bean
fun springSecurityConfig(http: HttpSecurity): SecurityFilterChain? {
http.authorizeRequests()
.antMatchers(
"/health-check/**", "/login**", "/favicon.ico", "/**.css", "/**.png"
).permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.and()
.csrf().disable()
return http.build()
}
}
I’ve made it allow unauthenticated requests to the health-check, paths that you need to get to in order to log in, and some static files.
In automated tests you don’t want any authentication at all (unless you’re going to test the authentication, which is a big topic that I’m going to call out of scope for this article).
Create a similar class somewhere under src/test/kotlin
:
@Configuration
@EnableWebSecurity
@Profile("test")
@Order(99)
class TestConfiguration {
@Bean
fun noSpringSecurityInTests(http: HttpSecurity): SecurityFilterChain? {
http.authorizeRequests().antMatchers("/**").permitAll()
return http.build()
}
}
Now Spring Security knows where to send browser users for authentication: OneLogin, with all the right parameters. However, OneLogin doesn’t know anything about your service being one of its clients and will reject all requests.
Create a OneLogin client app
This is mostly a matter of navigating to https://<your-subdomain>.onelogin.com
, clicking through their app creation UI and typing in details like the name of your app.
You can mostly follow their instructions but beware! Their code snippets refer to outdated versions of Spring and Spring Security, so use my code and config above. (This is why I didn’t link to the OneLogin docs at the top. I also found these Okta instructions helpful.)
Get your client id and secret from the OneLogin UI.
The id is public knowledge so you can put it into your application.yaml
, replacing the placeholder value that I wrote above.
The secret you should treat like a password and securely set in an environment variable everywhere that your service runs.
You can make separate OneLogin apps for dev and prod if you wish, in which case you will need to use a different yaml document in application.yaml
to set a client id for local development:
---
spring:
config:
activate:
on-profile: test
security:
oauth2:
client:
registration:
onelogin:
client-id: "id for a different OneLogin app"
You should still not record your app secret in your source code, even if it’s “just” for your local app.
Copy your redirect-uri
into the OneLogin UI and click save. It needs to be the same in your source code and in OneLogin. You can use the same different yaml documents and Spring profiles trick as above to set different values locally versus in prod.
Success!
Now you’re cookin’. Point your browser at http://localhost:9000/hello
and you should:
- Briefly see that URL in your address bar.
- Be redirected through about 5 URLs under
https://your-subdomain.onelogin.com
. (Log in if you have to.) - Be redirected back to your original URL.
- See
hello world
! - Refresh and observe that you are not redirected through OneLogin.
Tip: open your browser’s developer tools to the Network tab to watch the stream of URLs that it’s hitting.
… As long as you have only one node
This is a trap for the unwary.
If you deploy to prod and you have your service running on more than one node (or more than 1 Kubertnetes pod, or however it’s called by your PaaS/IaaS) then you may find some odd behaviour:
Authentication works. Then it doesn’t work, failing with some hard-to-understand errors from OneLogin or Spring Security about an “invalid request” or “invalid credentials”. Then it works for you but not your colleague. Then that reverses. It keeps flip-flopping between working and erroring.
What’s happening is that Spring Security caches something in memory about what requests it has redirected to OneLogin and if OneLogin redirects the browser back to a node that doesn’t have this in memory then it rejects the request. Sometimes it sends you back to OneLogin again, sometimes it decides that you’ve tried too frequently and just kills the request. I once had it bounce me to OneLogin 8 times in a row.
Here’s what’s happening:
Node 1 → OneLogin → Node 1 🎉 Success!
Node 1 → OneLogin → Node 2 ❌☠️✋⛔️ I never sent you to OneLogin!!!
This is a problem that I haven’t solved yet. I tried telling Spring to store session data in cookies and URLs but didn’t find anything that worked. It also didn’t help that the dev loop was so abysmally slow because I was deploying every time (it always worked locally because locally there’s always a single process).
In the end I worked around it (for now) by serving from a single node. 😉