Skip to content

Commit 4d0ce5c

Browse files
committed
feat(iam): add JDBC-backed authorization server
1 parent 678a552 commit 4d0ce5c

15 files changed

Lines changed: 423 additions & 0 deletions

iam/Dockerfile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# syntax=docker/dockerfile:1.19.0
2+
3+
# Build stage
4+
FROM ghcr.io/graalvm/native-image-community:25.0.1 AS build
5+
6+
WORKDIR /app
7+
8+
# Copy build files first for better layer caching
9+
COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts ./
10+
COPY gradle/ gradle/
11+
12+
# Download Gradle distribution
13+
RUN --mount=type=cache,target=/root/.gradle \
14+
./gradlew --version
15+
16+
# Copy source and build
17+
COPY core/ core/
18+
COPY console/ console/
19+
COPY iam/ iam/
20+
COPY migration/ migration/
21+
22+
RUN --mount=type=cache,target=/root/.gradle \
23+
./gradlew --no-daemon iam:nativeCompile
24+
25+
# Runtime stage
26+
FROM gcr.io/distroless/base-debian12:nonroot
27+
28+
WORKDIR /app
29+
30+
# Copy native executable
31+
COPY --from=build /app/iam/build/native/nativeCompile/iam ./iam
32+
33+
EXPOSE 9000
34+
35+
CMD ["/app/iam"]

iam/build.gradle.kts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import org.springframework.boot.gradle.tasks.bundling.BootJar
2+
3+
plugins {
4+
id("org.springframework.boot")
5+
id("io.spring.dependency-management")
6+
id("org.graalvm.buildtools.native")
7+
}
8+
9+
dependencies {
10+
implementation(project(":core"))
11+
implementation("org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server")
12+
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
13+
runtimeOnly("org.postgresql:postgresql")
14+
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
15+
testImplementation("org.springframework.boot:spring-boot-starter-security-oauth2-authorization-server-test")
16+
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
17+
}
18+
19+
tasks.withType<Test> {
20+
useJUnitPlatform()
21+
}
22+
23+
tasks.named<BootJar>("bootJar") {
24+
manifest.attributes["Implementation-Title"] = "Workastra IAM"
25+
}

iam/jvm.Dockerfile

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# syntax=docker/dockerfile:1.19.0
2+
3+
# Build stage
4+
FROM ghcr.io/graalvm/jdk-community:25.0.1 AS build
5+
6+
WORKDIR /app
7+
8+
# Copy build files first for better layer caching
9+
COPY gradlew gradlew.bat settings.gradle.kts build.gradle.kts ./
10+
COPY gradle/ gradle/
11+
12+
# Download Gradle distribution
13+
RUN --mount=type=cache,target=/root/.gradle \
14+
./gradlew --version
15+
16+
# Copy source and build
17+
COPY core/ core/
18+
COPY console/ console/
19+
COPY iam/ iam/
20+
COPY migration/ migration/
21+
22+
RUN --mount=type=cache,target=/root/.gradle \
23+
./gradlew --no-daemon iam:bootJar
24+
25+
# Runtime stage
26+
FROM eclipse-temurin:25-jre
27+
28+
WORKDIR /app
29+
30+
COPY --from=build /app/iam/build/libs/iam-*.jar iam.jar
31+
32+
USER 1000
33+
34+
EXPOSE 9000
35+
36+
CMD ["java", "-jar", "/app/iam.jar"]
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.workastra.iam;
2+
3+
import java.time.Duration;
4+
import java.util.concurrent.TimeoutException;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.boot.ApplicationArguments;
8+
import org.springframework.boot.ApplicationRunner;
9+
import org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerProperties;
10+
import org.springframework.integration.support.locks.DistributedLock;
11+
import org.springframework.integration.support.locks.LockRegistry;
12+
import org.springframework.security.crypto.password.PasswordEncoder;
13+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
14+
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
15+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
16+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
17+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
18+
import org.springframework.stereotype.Component;
19+
20+
@Component
21+
public class Bootstrap implements ApplicationRunner {
22+
23+
private static final Logger logger = LoggerFactory.getLogger(Bootstrap.class);
24+
private final RegisteredClientRepository registeredClientRepository;
25+
private final OAuth2AuthorizationServerProperties properties;
26+
private final PasswordEncoder passwordEncoder;
27+
private final LockRegistry<DistributedLock> lockRegistry;
28+
29+
public Bootstrap(
30+
LockRegistry<DistributedLock> lockRegistry,
31+
RegisteredClientRepository registeredClientRepository,
32+
OAuth2AuthorizationServerProperties properties,
33+
PasswordEncoder passwordEncoder
34+
) {
35+
this.lockRegistry = lockRegistry;
36+
this.registeredClientRepository = registeredClientRepository;
37+
this.properties = properties;
38+
this.passwordEncoder = passwordEncoder;
39+
}
40+
41+
@Override
42+
public void run(ApplicationArguments args) throws InterruptedException, TimeoutException {
43+
this.lockRegistry.executeLocked("iam.migration", Duration.ofSeconds(30), () -> {
44+
logger.info("Acquired lock for client registration migration");
45+
46+
var clients = this.properties.getClient();
47+
48+
logger.info("Upserting {} client(s) from application properties", clients.size());
49+
50+
for (var client : clients.entrySet()) {
51+
var id = client.getKey();
52+
var value = client.getValue();
53+
54+
logger.info("Registering client with id: {}", id);
55+
56+
var registration = value.getRegistration();
57+
RegisteredClient registeredClient = RegisteredClient.withId(id)
58+
.clientName(registration.getClientName())
59+
.clientId(registration.getClientId())
60+
.clientSecret(
61+
registration.getClientSecret() == null
62+
? ""
63+
: this.passwordEncoder.encode(registration.getClientSecret())
64+
)
65+
.clientAuthenticationMethods(methods -> {
66+
methods.clear();
67+
registration
68+
.getClientAuthenticationMethods()
69+
.forEach(method -> methods.add(ClientAuthenticationMethod.valueOf(method)));
70+
})
71+
.authorizationGrantTypes(types -> {
72+
types.clear();
73+
registration
74+
.getAuthorizationGrantTypes()
75+
.forEach(type -> types.add(new AuthorizationGrantType(type)));
76+
})
77+
.redirectUris(uris -> {
78+
uris.clear();
79+
uris.addAll(registration.getRedirectUris());
80+
})
81+
.scopes(scopes -> {
82+
scopes.clear();
83+
scopes.addAll(registration.getScopes());
84+
})
85+
.postLogoutRedirectUris(uris -> {
86+
uris.clear();
87+
uris.addAll(registration.getPostLogoutRedirectUris());
88+
})
89+
.clientSettings(
90+
ClientSettings.builder()
91+
.requireAuthorizationConsent(value.isRequireAuthorizationConsent())
92+
.requireProofKey(value.isRequireProofKey())
93+
.build()
94+
)
95+
.build();
96+
97+
this.registeredClientRepository.save(registeredClient);
98+
99+
logger.info("Client with id: {} registered successfully", registration.getClientId());
100+
}
101+
});
102+
}
103+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.workastra.iam;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class IAMApplication {
8+
9+
static void main(String[] args) {
10+
SpringApplication.run(IAMApplication.class, args);
11+
}
12+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.workastra.iam.configuration;
2+
3+
import java.util.Map;
4+
import java.util.Objects;
5+
import java.util.function.Function;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.core.annotation.Order;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.jdbc.core.JdbcTemplate;
11+
import org.springframework.security.config.Customizer;
12+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
13+
import org.springframework.security.core.Authentication;
14+
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
15+
import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
16+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
17+
import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
18+
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
19+
import org.springframework.security.web.SecurityFilterChain;
20+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
21+
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
22+
23+
@Configuration
24+
public class AuthorizationServerConfiguration {
25+
26+
@Bean
27+
RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
28+
return new JdbcRegisteredClientRepository(jdbcTemplate);
29+
}
30+
31+
@Bean
32+
@Order(1)
33+
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
34+
Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper = (context) -> {
35+
Authentication auth = context.getAuthentication();
36+
Object principal = Objects.requireNonNull(auth.getPrincipal(), "Authentication principal must not be null");
37+
38+
// Only one principal type for now, but switch is intentional.
39+
// Adding a new type later means just appending a new case, without touching or risking the existing logic.
40+
@SuppressWarnings("SwitchStatementWithTooFewBranches")
41+
Map<String, Object> claims = switch (principal) {
42+
case JwtAuthenticationToken jwt -> jwt.getToken().getClaims();
43+
default -> throw new IllegalStateException(
44+
"Unsupported principal type: " + principal.getClass().getName()
45+
);
46+
};
47+
48+
return new OidcUserInfo(claims);
49+
};
50+
51+
http
52+
.oauth2AuthorizationServer((authorizationServer) -> {
53+
http.securityMatcher(authorizationServer.getEndpointsMatcher());
54+
55+
authorizationServer.oidc((oidc) ->
56+
oidc.userInfoEndpoint((userInfo) -> userInfo.userInfoMapper(userInfoMapper))
57+
);
58+
})
59+
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
60+
// Redirect to the login page when not authenticated from the authorization endpoint
61+
.exceptionHandling((exceptions) ->
62+
exceptions.defaultAuthenticationEntryPointFor(
63+
new LoginUrlAuthenticationEntryPoint("/login"),
64+
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
65+
)
66+
);
67+
68+
return http.build();
69+
}
70+
71+
@Bean
72+
@Order(2)
73+
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
74+
http
75+
.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
76+
// Enable form login with default settings
77+
.formLogin(Customizer.withDefaults());
78+
// Enable OAuth2 federated identity login with default settings
79+
// .oauth2Login(Customizer.withDefaults());
80+
81+
return http.build();
82+
}
83+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package com.workastra.iam.configuration;
3+
4+
import org.jspecify.annotations.NullMarked;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.workastra.iam.entity;
2+
3+
import jakarta.persistence.Entity;
4+
import jakarta.persistence.GeneratedValue;
5+
import jakarta.persistence.Id;
6+
import jakarta.persistence.Table;
7+
import java.io.Serial;
8+
import java.util.Collection;
9+
import java.util.List;
10+
import java.util.UUID;
11+
import lombok.AllArgsConstructor;
12+
import lombok.Builder;
13+
import lombok.NoArgsConstructor;
14+
import org.jspecify.annotations.Nullable;
15+
import org.springframework.security.core.CredentialsContainer;
16+
import org.springframework.security.core.GrantedAuthority;
17+
import org.springframework.security.core.userdetails.UserDetails;
18+
19+
@Table(name = "users")
20+
@Entity
21+
@Builder
22+
@NoArgsConstructor
23+
@AllArgsConstructor
24+
public class User implements UserDetails, CredentialsContainer {
25+
26+
@Serial
27+
private static final long serialVersionUID = 1L;
28+
29+
@Id
30+
@GeneratedValue
31+
private UUID id;
32+
33+
private String username;
34+
35+
private @Nullable String password;
36+
37+
@Override
38+
public Collection<? extends GrantedAuthority> getAuthorities() {
39+
return List.of();
40+
}
41+
42+
@Override
43+
public String getUsername() {
44+
return this.username;
45+
}
46+
47+
@Override
48+
public @Nullable String getPassword() {
49+
return this.password;
50+
}
51+
52+
@Override
53+
public void eraseCredentials() {
54+
this.password = null;
55+
}
56+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package com.workastra.iam.entity;
3+
4+
import org.jspecify.annotations.NullMarked;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package com.workastra.iam;
3+
4+
import org.jspecify.annotations.NullMarked;

0 commit comments

Comments
 (0)