Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/openapi/components/schemas/SignInStartResponse.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,5 @@ properties:
options:
type: string
description: >
JSON string representing the options argument that should be passed to `navigator.credentials.get(options)` if
JSON object representing the options argument that should be passed to `navigator.credentials.get(options)` if
`accountExists` is `true` or to `navigator.credentials.create(options)` if `accountExists` is `false`.
x-field-extra-annotation: '@com.fasterxml.jackson.annotation.JsonRawValue'
3 changes: 1 addition & 2 deletions docs/openapi/components/schemas/SignUpStartResponse.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,4 @@ properties:
description: Unique identifier for the sign-up request.
options:
type: string
description: Options to pass to `navigator.credentials.create()`
x-field-extra-annotation: '@com.fasterxml.jackson.annotation.JsonRawValue'
description: Options to pass to `navigator.credentials.create()` serialized in a JSON string
8 changes: 8 additions & 0 deletions docs/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ components:
type: apiKey
name: X-Api-Key
in: header
app-id:
type: apiKey
name: X-App-Id
in: header
app-api-key:
type: apiKey
name: X-Api-Key
in: header
2 changes: 2 additions & 0 deletions docs/openapi/paths/v1_signin_finish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ post:
application/json:
schema:
$ref: ../components/schemas/SignInFinishResponse.yaml
security:
- app-api-key: []
2 changes: 2 additions & 0 deletions docs/openapi/paths/v1_signin_start.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ post:
application/json:
schema:
$ref: ../components/schemas/SignInStartResponse.yaml
security:
- app-id: []
2 changes: 2 additions & 0 deletions docs/openapi/paths/v1_signup_finish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ post:
application/json:
schema:
$ref: ../components/schemas/SignUpFinishResponse.yaml
security:
- app-api-key: []
2 changes: 2 additions & 0 deletions docs/openapi/paths/v1_signup_start.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ post:
application/json:
schema:
$ref: ../components/schemas/SignUpStartResponse.yaml
security:
- app-id: []
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
*/
@Service
@RequiredArgsConstructor
public class RequestHeaderAuthenticationProvider implements AuthenticationProvider {
public class AdminApiAuthenticationProvider implements AuthenticationProvider {

private final AdminConfigProperties adminConfigProperties;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.helioauth.passkeys.api.auth;

import com.helioauth.passkeys.api.domain.ClientApplication;
import com.helioauth.passkeys.api.domain.ClientApplicationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ApplicationApiKeyAuthenticationProvider implements AuthenticationProvider {

private final ClientApplicationRepository clientApplicationRepository;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String apiKeyHeader = (String) authentication.getPrincipal();

if (apiKeyHeader == null || apiKeyHeader.isBlank()) {
throw new BadCredentialsException("Application API key header is missing or empty");
}

ClientApplication clientApp = clientApplicationRepository.findByApiKey(apiKeyHeader)
.orElseThrow(() -> new BadCredentialsException("Invalid api key"));

PreAuthenticatedAuthenticationToken authenticatedToken = new PreAuthenticatedAuthenticationToken(
clientApp.getId(),
clientApp,
List.of(new SimpleGrantedAuthority("ROLE_APPLICATION"))
);
authenticatedToken.setDetails(clientApp);

return authenticatedToken;
}

@Override
public boolean supports(Class<?> authentication) {
return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.helioauth.passkeys.api.auth;

import com.helioauth.passkeys.api.domain.ClientApplication;
import com.helioauth.passkeys.api.domain.ClientApplicationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class ApplicationIdAuthenticationProvider implements AuthenticationProvider {

private final ClientApplicationRepository clientApplicationRepository;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String appIdHeader = (String) authentication.getPrincipal();

if (appIdHeader == null || appIdHeader.isBlank()) {
throw new BadCredentialsException("Application ID header is missing or empty");
}

try {
UUID appId = UUID.fromString(appIdHeader);
ClientApplication clientApp = clientApplicationRepository.findById(appId)
.orElseThrow(() -> new BadCredentialsException("Invalid application ID"));

PreAuthenticatedAuthenticationToken authenticatedToken = new PreAuthenticatedAuthenticationToken(
clientApp.getId(),
clientApp,
List.of(new SimpleGrantedAuthority("ROLE_FRONTEND_APPLICATION"))
);
authenticatedToken.setDetails(clientApp);

return authenticatedToken;
} catch (IllegalArgumentException e) {
throw new BadCredentialsException("Invalid application ID format");
}
}

@Override
public boolean supports(Class<?> authentication) {
return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@

package com.helioauth.passkeys.api.config;

import com.helioauth.passkeys.api.auth.RequestHeaderAuthenticationProvider;
import com.helioauth.passkeys.api.auth.AdminApiAuthenticationProvider;
import com.helioauth.passkeys.api.auth.ApplicationApiKeyAuthenticationProvider;
import com.helioauth.passkeys.api.auth.ApplicationIdAuthenticationProvider;
import com.helioauth.passkeys.api.config.properties.AdminConfigProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
Expand All @@ -43,19 +46,27 @@
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final RequestHeaderAuthenticationProvider requestHeaderAuthenticationProvider;
private final AdminApiAuthenticationProvider adminApiAuthenticationProvider;
private final ApplicationIdAuthenticationProvider applicationIdAuthenticationProvider;
private final ApplicationApiKeyAuthenticationProvider applicationApiKeyAuthenticationProvider;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter) throws Exception {
RequestHeaderAuthenticationFilter adminAuthFilter,
RequestHeaderAuthenticationFilter applicationIdAuthFilter,
RequestHeaderAuthenticationFilter applicationApiKeyAuthFilter) throws Exception {

http
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterAfter(requestHeaderAuthenticationFilter, HeaderWriterFilter.class)
.addFilterAfter(adminAuthFilter, HeaderWriterFilter.class)
.addFilterAfter(applicationIdAuthFilter, HeaderWriterFilter.class)
.addFilterAfter(applicationApiKeyAuthFilter, HeaderWriterFilter.class)
.authorizeHttpRequests(registry -> registry
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/v1/signup/start").hasRole("FRONTEND_APPLICATION")
.requestMatchers("/v1/signup/finish").hasRole("APPLICATION")
.anyRequest().permitAll()
)
.exceptionHandling(config -> config
Expand All @@ -66,19 +77,47 @@ public SecurityFilterChain filterChain(HttpSecurity http,
}

@Bean
public RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter(AuthenticationManager authenticationManager,
AdminConfigProperties adminConfigProperties) {
public RequestHeaderAuthenticationFilter adminAuthFilter(AdminConfigProperties adminConfigProperties) {
RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter();
filter.setPrincipalRequestHeader(adminConfigProperties.getAuth().getHeaderName());
filter.setExceptionIfHeaderMissing(false);
filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/**"));
filter.setAuthenticationManager(authenticationManager);
filter.setAuthenticationManager(adminAuthenticationManager());

return filter;
}

@Bean
protected AuthenticationManager authenticationManager() {
return new ProviderManager(List.of(requestHeaderAuthenticationProvider));
public RequestHeaderAuthenticationFilter applicationIdAuthFilter(@Value("${app.auth.app-id-header}") String authHeader) {
RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter();
filter.setPrincipalRequestHeader(authHeader);
filter.setExceptionIfHeaderMissing(false);
filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/v1/signup/start"));
filter.setAuthenticationManager(appIdAuthenticationManager());

return filter;
}

@Bean
public RequestHeaderAuthenticationFilter applicationApiKeyAuthFilter(@Value("${app.auth.api-key-header}") String authHeader) {
RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter();
filter.setPrincipalRequestHeader(authHeader);
filter.setExceptionIfHeaderMissing(false);
filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/v1/signup/finish"));
filter.setAuthenticationManager(appApiKeyAuthenticationManager());

return filter;
}

protected AuthenticationManager adminAuthenticationManager() {
return new ProviderManager(List.of(adminApiAuthenticationProvider));
}

protected AuthenticationManager appIdAuthenticationManager() {
return new ProviderManager(List.of(applicationIdAuthenticationProvider));
}

protected AuthenticationManager appApiKeyAuthenticationManager() {
return new ProviderManager(List.of(applicationApiKeyAuthenticationProvider));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;
import java.util.UUID;

public interface ClientApplicationRepository extends JpaRepository<ClientApplication, UUID> {
Optional<ClientApplication> findByApiKey(String s);
}
5 changes: 5 additions & 0 deletions src/main/resources/application-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ admin:
auth:
api-key: testapikey
header-name: X-Api-Key

app:
auth:
app-id-header: X-App-Id
api-key-header: X-Api-Key
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ relying-party:
display-name: HelioAuth Passkeys API
hostname: localhost
allow-origin-port: false

app:
auth:
app-id-header: X-App-Id
api-key-header: X-Api-Key
Loading
Loading