Skip to content

Commit

Permalink
14601 authorization api (#16495)
Browse files Browse the repository at this point in the history
  • Loading branch information
jalbinson authored Dec 5, 2024
1 parent 53d01d0 commit 66c3691
Show file tree
Hide file tree
Showing 39 changed files with 1,206 additions and 81 deletions.
14 changes: 7 additions & 7 deletions auth/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
apply(from = rootProject.file("buildSrc/shared.gradle.kts"))

plugins {
id("org.springframework.boot") version "3.3.5"
id("org.springframework.boot") version "3.4.0"
id("io.spring.dependency-management") version "1.1.6"
id("reportstream.project-conventions")
kotlin("plugin.spring") version "2.0.21"
Expand All @@ -26,16 +26,16 @@ dependencies {

runtimeOnly("com.nimbusds:oauth2-oidc-sdk:11.20.1")

// okta
implementation("com.okta.sdk:okta-sdk-api:20.0.0")
runtimeOnly("com.okta.sdk:okta-sdk-impl:20.0.0")

// Swagger
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.6.0")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")

testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner")

compileOnly("org.springframework.boot:spring-boot-devtools")
}
Expand All @@ -49,7 +49,7 @@ configurations.all {
dependencyManagement {
imports {
mavenBom("com.azure.spring:spring-cloud-azure-dependencies:5.18.0")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.3")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.0")
}
}

Expand Down
56 changes: 56 additions & 0 deletions auth/docs/setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Running the Auth Microservice

## Prerequisites

A few secrets are required to run the Auth which are not committed to source. These values are
configured in Okta.

| Environment variable | Value |
|----------------------|---------------------------------|
| OKTA_ADMIN_CLIENT_API_ENCODED_PRIVATE_KEY | Base 64 encoded private key pem |
| SPRING_SECURITY_OAUTH2_RESOURCESERVER_OPAQUETOKEN_CLIENT_SECRET | Base 64 encoded secret |

## How to run application locally

```bash
# from project root
# start ReportStream and all dependent docker containers
./gradlew quickRun
# start submissions service
./ gradlew submissions:bootRun
# start auth service
./gradlew auth:bootRun
```

## Setup a Sender

- Sign in to Admin Okta
- Applications -> Application tab
- Click "Create App Integration"
- Select "API Services" and click next
- Name your sender
- Copy your client ID and client secret or private key locally to be used while calling the /token endpoint
- Add the user to the appropriate sender group
- You can find this option on the small gear next to your newly created application
- Ensure the group has the prefix DHSender_

## Submitting reports locally

- Retrieve an access token directly from Okta and ensure the JWT contains the "sender" scope
- Make a well-formed request to https://reportstream.oktapreview.com/oauth2/default/v1/token to retrieve your access token
- [See Okta documentation on that endpoint here](https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/#get-an-access-token)
- Submit your report to http://localhost:9000/api/v1/reports
- Note the it's port 9000 which is auth rather than directly to 8880 which is submissions
- See endpoint definition in [SubmissionController](../../submissions/src/main/kotlin/gov/cdc/prime/reportstream/submissions/controllers/SubmissionController.kt)
- Add the access token you retrieved from Okta as a `Bearer` token in the `Authorization` header
- Inspect the logs if you received a 401 or a 403. This indicates there is an issue with your access token.

## Notes on secrets

The Okta-Groups JWT signing key pair has a local dev value already set up appropriately in auth and
downstream in submissions. New values _must_ be generated for deployed environments. You can look
at [KeyGenerationUtils](../src/test/kotlin/gov/cdc/prime/reportstream/auth/util/KeyGenerationUtils.kt)
for scripts to run to generate new keys.

By Default, we are connecting to the Staging Okta. We cannot post connection secrets directly in this document so
you will have to ask someone for those values.
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@ object AuthApplicationConstants {
const val HEALTHCHECK_ENDPOINT_V1 = "/api/v1/healthcheck"
}

/**
* All Submissions service endpoints defined here
*/
object SubmissionsEndpoints {
const val REPORTS_ENDPOINT_V1 = "/api/v1/reports"
object Scopes {
const val ORGANIZATION_SCOPE = "organization"
const val SUBJECT_SCOPE = "sub"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package gov.cdc.prime.reportstream.auth.client

import com.okta.sdk.resource.api.ApplicationGroupsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.apache.logging.log4j.kotlin.Logging
import org.springframework.stereotype.Service

@Service
class OktaGroupsClient(
private val applicationGroupsApi: ApplicationGroupsApi,
) : Logging {

/**
* Get all application groups from the Okta Admin API
*
* Group names are found at json path "_embedded.group.profile.name"
*
* @see https://developer.okta.com/docs/api/openapi/okta-management/management/tag/ApplicationGroups/#tag/ApplicationGroups/operation/listApplicationGroupAssignments
*/
suspend fun getApplicationGroups(appId: String): List<String> {
return withContext(Dispatchers.IO) {
try {
val groups = applicationGroupsApi
.listApplicationGroupAssignments(appId, null, null, null, "group")
.map { it.embedded?.get("group") as Map<*, *> }
.map { it["profile"] as Map<*, *> }
.map { it["name"] as String }
logger.info("$appId is a member of ${groups.joinToString()}")
groups
} catch (ex: Exception) {
logger.error("Error retrieving application groups from Okta API", ex)
throw ex
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ package gov.cdc.prime.reportstream.auth.config

import gov.cdc.prime.reportstream.auth.model.Environment
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.ConstructorBinding
import org.springframework.context.annotation.Bean
import java.time.Clock
import kotlin.time.TimeSource

/**
* Simple class to automatically read configuration from application.yml (or environment variable overrides)
*/
@ConfigurationProperties(prefix = "app")
data class ApplicationConfig @ConstructorBinding constructor(
data class ApplicationConfig(
val environment: Environment,
) {

@Bean
fun timeSource(): TimeSource {
return TimeSource.Monotonic
}

@Bean
fun clock(): Clock {
return Clock.systemUTC()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package gov.cdc.prime.reportstream.auth.config

import com.okta.sdk.client.AuthorizationMode
import com.okta.sdk.client.Clients
import com.okta.sdk.resource.api.ApplicationGroupsApi
import com.okta.sdk.resource.client.ApiClient
import gov.cdc.prime.reportstream.shared.StringUtilities.base64Decode
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile

@Configuration
@Profile("!test")
class OktaClientConfig(
private val oktaClientProperties: OktaClientProperties,
) {

@Bean
fun apiClient(): ApiClient {
return Clients.builder()
.setOrgUrl(oktaClientProperties.orgUrl)
.setAuthorizationMode(AuthorizationMode.PRIVATE_KEY)
.setClientId(oktaClientProperties.clientId)
.setScopes(oktaClientProperties.requiredScopes)
.setPrivateKey(oktaClientProperties.apiPrivateKey)
// .setCacheManager(...) TODO: investigate caching since groups don't often change
.build()
}

@Bean
fun applicationGroupsApi(): ApplicationGroupsApi {
return ApplicationGroupsApi(apiClient())
}

@ConfigurationProperties(prefix = "okta.admin-client")
data class OktaClientProperties(
val orgUrl: String,
val clientId: String,
val requiredScopes: Set<String>,
private val apiEncodedPrivateKey: String,
) {
// PEM encoded format
val apiPrivateKey = apiEncodedPrivateKey.base64Decode()
}
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class HealthController(

private val applicationStart = timeSource.markNow()

/**
* Simple endpoint that returns a healthcheck with uptime
*/
@GetMapping(
AuthApplicationConstants.Endpoints.HEALTHCHECK_ENDPOINT_V1,
produces = [MediaType.APPLICATION_JSON_VALUE]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package gov.cdc.prime.reportstream.auth.filter

import gov.cdc.prime.reportstream.auth.AuthApplicationConstants
import gov.cdc.prime.reportstream.auth.service.OktaGroupsService
import gov.cdc.prime.reportstream.shared.auth.jwt.OktaGroupsJWTConstants
import kotlinx.coroutines.reactor.mono
import org.springframework.cloud.gateway.filter.GatewayFilter
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthentication
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono

/**
* This filter defines how the Okta-Groups header is added to requests. It follows the conventions
* defined in spring-cloud-gateway and is instantiated via configuration under a route's filters.
*/
@Component
class AppendOktaGroupsGatewayFilterFactory(
private val oktaGroupsService: OktaGroupsService,
) : AbstractGatewayFilterFactory<Any>() {

/**
* function used only in testing to create our filter without any configuration
*/
fun apply(): GatewayFilter {
return apply { _: Any? -> }
}

override fun apply(config: Any?): GatewayFilter {
return GatewayFilter { exchange, chain ->
exchange
.getPrincipal<BearerTokenAuthentication>()
.flatMap { oktaAccessTokenJWT ->
val appId = oktaAccessTokenJWT
.tokenAttributes[AuthApplicationConstants.Scopes.SUBJECT_SCOPE] as String
val organizations = oktaAccessTokenJWT
.tokenAttributes[AuthApplicationConstants.Scopes.ORGANIZATION_SCOPE] as List<*>?

// If there is no organization claim present, then we have an application user and
// require appending our custom header
if (organizations == null) {
mono { oktaGroupsService.generateOktaGroupsJWT(appId) }
} else {
Mono.empty()
}
}
.map { oktaGroupsJWT: String ->
exchange.request
.mutate()
.headers {
it.add(OktaGroupsJWTConstants.OKTA_GROUPS_HEADER, oktaGroupsJWT)
}
.build()
}
.switchIfEmpty(Mono.just(exchange.request)) // drop back in original unmodified request if not an app
.flatMap { request ->
chain.filter(exchange.mutate().request(request).build())
}
}
}
}
Loading

0 comments on commit 66c3691

Please sign in to comment.