Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

14601 authorization api #16495

Merged
merged 40 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d530487
15864 Spring Cloud Gate configuration with Swagger
jalbinson Oct 21, 2024
e68207f
Merge branch 'master' into platform/jamie/15864-spring-cloud-swagger
jalbinson Oct 21, 2024
3403b45
add basic healthcheck test
jalbinson Oct 21, 2024
cbe8404
Merge branch 'platform/jamie/15864-spring-cloud-swagger' of github.co…
jalbinson Oct 21, 2024
8e64119
Merge branch 'master' into platform/jamie/15864-spring-cloud-swagger
jalbinson Oct 21, 2024
74046c6
Merge branch 'master' into platform/jamie/15864-spring-cloud-swagger
jalbinson Oct 22, 2024
9d0dc6f
PR feedback
jalbinson Oct 22, 2024
2986d9c
Merge branch 'master' into platform/jamie/15864-spring-cloud-swagger
jalbinson Oct 22, 2024
652a45e
Merge branch 'platform/jamie/15864-spring-cloud-swagger' of github.co…
jalbinson Oct 22, 2024
a94be0b
config injection cleanup
jalbinson Oct 22, 2024
74a903a
Merge branch 'master' into platform/jamie/15864-spring-cloud-swagger
jalbinson Oct 22, 2024
c093548
fix test
jalbinson Oct 22, 2024
ceea1f7
Merge branch 'platform/jamie/15864-spring-cloud-swagger' of github.co…
jalbinson Oct 22, 2024
df7bac8
14601 authz api
jalbinson Nov 6, 2024
da94858
merge main
jalbinson Nov 6, 2024
c000475
fix merge issue
jalbinson Nov 6, 2024
9ea32e5
comments
jalbinson Nov 6, 2024
c9c2ba7
Merge branch 'main' into platform/jamie/14601-authz-api
emvaldes Nov 7, 2024
e24d2a8
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 7, 2024
6ddc157
doc
jalbinson Nov 7, 2024
77efd61
Merge branch 'platform/jamie/14601-authz-api' of github.com:CDCgov/pr…
jalbinson Nov 7, 2024
75862cc
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 7, 2024
74d025d
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 12, 2024
d290bc7
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 13, 2024
1ace3d1
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 14, 2024
3cb1171
Update auth/docs/setup.md
jalbinson Nov 19, 2024
54fb1ba
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 19, 2024
85abc4a
PR review
jalbinson Nov 19, 2024
a60f074
Merge branch 'platform/jamie/14601-authz-api' of github.com:CDCgov/pr…
jalbinson Nov 19, 2024
f0bf94f
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 21, 2024
dad9f2c
latest version of okta SDK after fix
jalbinson Nov 22, 2024
60f59f7
Merge branch 'platform/jamie/14601-authz-api' of github.com:CDCgov/pr…
jalbinson Nov 22, 2024
6d902c7
revert spring boot update
jalbinson Nov 22, 2024
bd308f4
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Nov 25, 2024
3d6faf3
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Dec 2, 2024
f548a03
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Dec 3, 2024
61b9a8f
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Dec 4, 2024
e5fa576
various changes required for spring boot 3.4.0 on both auth and submi…
jalbinson Dec 4, 2024
494135e
Merge branch 'platform/jamie/14601-authz-api' of github.com:CDCgov/pr…
jalbinson Dec 4, 2024
b1339ae
Merge branch 'main' into platform/jamie/14601-authz-api
jalbinson Dec 5, 2024
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
7 changes: 6 additions & 1 deletion 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.3.4" // 3.3.5 has breaking bug for appending headers https://github.com/spring-projects/spring-framework/issues/33789
jalbinson marked this conversation as resolved.
Show resolved Hide resolved
id("io.spring.dependency-management") version "1.1.6"
id("reportstream.project-conventions")
kotlin("plugin.spring") version "2.0.21"
Expand All @@ -26,11 +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.springframework.cloud:spring-cloud-starter-contract-stub-runner")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.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
jalbinson marked this conversation as resolved.
Show resolved Hide resolved
- 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.
jalbinson marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,9 @@ 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,44 @@
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

@Configuration
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a ticket for this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just leave this be for now. Caching is something we can worry about if performance becomes a problem especially since this is currently a POC.

.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())
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package gov.cdc.prime.reportstream.auth.service

import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.jwk.JWK
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import gov.cdc.prime.reportstream.shared.StringUtilities.base64Decode
import gov.cdc.prime.reportstream.shared.auth.jwt.OktaGroupsJWT
import gov.cdc.prime.reportstream.shared.auth.jwt.OktaGroupsJWTConstants
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Service
import java.time.Clock
import java.time.Duration
import java.util.Date
import java.util.UUID

@Service
class OktaGroupsJWTWriter(
private val jwtConfig: OktaGroupsJWTConfig,
private val clock: Clock,
) {

/**
* generate and sign our custom JWT containing Okta group information for a particular application
*/
fun write(model: OktaGroupsJWT): String {
jalbinson marked this conversation as resolved.
Show resolved Hide resolved
val now = clock.instant()
val expires = now.plus(jwtConfig.ttl)
val nbf = now.minus(jwtConfig.nbf)
val claimsSetBuilder = JWTClaimsSet.Builder()
.subject(model.appId)
.issuer(jwtConfig.issuer)
.jwtID(UUID.randomUUID().toString())
.issueTime(Date.from(now))
.notBeforeTime(Date.from(nbf))
.expirationTime(Date.from(expires))
.claim(OktaGroupsJWTConstants.OKTA_GROUPS_JWT_GROUP_CLAIM, model.groups)

val signedJWT = SignedJWT(
JWSHeader.Builder(JWSAlgorithm.RS256).build(),
claimsSetBuilder.build()
)

signedJWT.sign(RSASSASigner(jwtConfig.jwtPrivateKeyJWK.toRSAKey()))

return signedJWT.serialize()
}

/**
* Configuration for Submissions microservice
*/
@ConfigurationProperties(prefix = "okta.jwt")
data class OktaGroupsJWTConfig(
private val jwtEncodedPrivateKeyJWK: String,
val ttl: Duration,
val nbf: Duration,
val issuer: String,
) {
// JWK json format
val jwtPrivateKeyJWK: JWK = JWK.parse(jwtEncodedPrivateKeyJWK.base64Decode())
}
}
Loading
Loading