Skip to content

Commit dfcb5b2

Browse files
authored
Merge pull request #89 from toolsplus/feature/support-forge-system-token-storage
Add Forge System Access Token repository
2 parents 908bd46 + d03f9d3 commit dfcb5b2

File tree

15 files changed

+532
-208
lines changed

15 files changed

+532
-208
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.toolsplus.atlassian.connect.play.api.models
2+
import java.time.Instant
3+
4+
/**
5+
* Default case class implementation of [[ForgeSystemAccessToken]]
6+
*/
7+
case class DefaultForgeSystemAccessToken(installationId: String,
8+
apiBaseUrl: String,
9+
accessToken: String,
10+
expirationTime: Instant)
11+
extends ForgeSystemAccessToken
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.toolsplus.atlassian.connect.play.api.models
2+
3+
import java.time.Instant
4+
5+
/**
6+
* Forge system access token
7+
*
8+
* @see https://developer.atlassian.com/platform/forge/remote/essentials/
9+
*/
10+
trait ForgeSystemAccessToken {
11+
12+
/**
13+
* Unique identifier of a Forge installation in ARI format, e.g. ari:cloud:ecosystem::installation/c3658f0f-8380-41e5-bb1e-68903f8efdca.
14+
*
15+
* This ID is stable across upgrades but not uninstallation and re-installation. This value should be used to key
16+
* Forge-related tenant details in the app.
17+
*
18+
* @return Forge installation id.
19+
*/
20+
def installationId: String
21+
22+
/**
23+
* OAuth2 API base URL in the format https://api.atlassian.com/ex/confluence/00000000-0000-0000-0000-000000000000
24+
* @return OAuth2 API base URL
25+
*/
26+
def apiBaseUrl: String
27+
28+
/**
29+
* System access token this entity represents.
30+
* @return System access token
31+
*/
32+
def accessToken: String
33+
34+
/**
35+
* Timestamp when this token expires.
36+
* @return Access token expiry time.
37+
*/
38+
def expirationTime: Instant
39+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package io.toolsplus.atlassian.connect.play.api.repositories
2+
3+
import io.toolsplus.atlassian.connect.play.api.models.ForgeSystemAccessToken
4+
5+
import java.time.Instant
6+
import scala.concurrent.Future
7+
8+
trait ForgeSystemAccessTokenRepository {
9+
10+
def all(): Future[Seq[ForgeSystemAccessToken]]
11+
12+
/** Saves the given Forge system access token by inserting it if it does not
13+
* exist or updating an existing record if it's already present.
14+
*
15+
* @param token
16+
* Forge system access token to store.
17+
* @return
18+
* Saved Forge system access token.
19+
*/
20+
def save(token: ForgeSystemAccessToken): Future[ForgeSystemAccessToken]
21+
22+
/** Finds the access token for the given installation ID. If an access token
23+
* is returned, it may be expired.
24+
*
25+
* @param installationId
26+
* ID of the installation associated with the access token
27+
* @return
28+
* Access token and with metadata, if one is found.
29+
*/
30+
def findByInstallationId(
31+
installationId: String
32+
): Future[Option[ForgeSystemAccessToken]]
33+
34+
/** Find the access token that is not yet expired
35+
*
36+
* @param installationId
37+
* ID of the installation associated with the access token
38+
* @param expirationTime
39+
* In most cases, specify the timestamp as of the moment this method is
40+
* called with some leeway
41+
* @return
42+
* Valid access token and with metadata, if one is found.
43+
*/
44+
def findByInstallationIdAndExpirationTimeAfter(
45+
installationId: String,
46+
expirationTime: Instant
47+
): Future[Option[ForgeSystemAccessToken]]
48+
49+
/** Clean up stale records
50+
*
51+
* @param expirationTime
52+
* In most cases, specify the timestamp as of the moment this method is
53+
* called
54+
* @return
55+
* Number of affected rows
56+
*/
57+
def deleteAllByExpirationTimeBefore(expirationTime: Instant): Future[Int]
58+
59+
}

modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeInvocationContext.scala

Lines changed: 91 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,110 @@ package io.toolsplus.atlassian.connect.play.auth.frc.jwt
33
import com.nimbusds.jose.proc.SecurityContext
44
import io.circe.JsonObject
55

6-
/**
7-
* Environment provides information about the Forge environment the app is running in
8-
* @param `type` Forge environment type associated with a Forge Remote call, such as DEVELOPMENT, STAGING, PRODUCTION
9-
* @param id Forge environment id associated with a Forge Remote call
6+
/** Environment provides information about the Forge environment the app is
7+
* running in
8+
* @param `type`
9+
* Forge environment type associated with a Forge Remote call, such as
10+
* DEVELOPMENT, STAGING, PRODUCTION
11+
* @param id
12+
* Forge environment id associated with a Forge Remote call
1013
*/
1114
final case class Environment(`type`: String, id: String)
1215

13-
/**
14-
* Module provides information about the module that initiated a Forge remote call.
16+
/** Module provides information about the module that initiated a Forge remote
17+
* call.
1518
*
16-
* @param `type` Module type initiating the remote call, such as `xen:macro` for front-end invocations. Otherwise, it will be core:endpoint. To determine the type of module that specified the remote resolver, refer to `context.extension.type`
17-
* @param key Forge module key for this endpoint as specified in the manifest.yml
19+
* @param `type`
20+
* Module type initiating the remote call, such as `xen:macro` for front-end
21+
* invocations. Otherwise, it will be core:endpoint. To determine the type of
22+
* module that specified the remote resolver, refer to
23+
* `context.extension.type`
24+
* @param key
25+
* Forge module key for this endpoint as specified in the manifest.yml
1826
*/
1927
final case class Module(`type`: String, key: String)
2028

21-
/**
22-
* Contains information about the license of the app. This field is only present for paid apps in the production environment.
23-
* `license` is undefined for free apps, apps in DEVELOPMENT and STAGING environments, and apps that are not listed on
24-
* the Atlassian Marketplace.
29+
/** Context where an app is installed.
2530
*
26-
* @param isActive Specifies if the license is active.
31+
* @param name
32+
* Name of the context where an app is installed.
33+
* @param apiBaseUrl
34+
* API base URL where all Atlassian app API requests should be routed.
2735
*/
28-
final case class License(isActive: Boolean)
36+
final case class InstallationContext(name: String, apiBaseUrl: String)
37+
38+
/** Information about an app installation.
39+
* @param id
40+
* Identifier for the specific installation of an app. Any remote storage
41+
* should be keyed against this value.
42+
* @param contexts
43+
* List of contexts where the app is installed
44+
*/
45+
final case class Installation(id: String, contexts: Seq[InstallationContext])
2946

30-
/**
47+
/** Contains information about the license of the app. This field is only
48+
* present for paid apps in the production environment. `license` is undefined
49+
* for free apps, apps in DEVELOPMENT and STAGING environments, and apps that
50+
* are not listed on the Atlassian Marketplace.
51+
*
52+
* There are many attributes missing here since it is not clear from the
53+
* documentation if they will be available when the license object is present
54+
* in a request. Refer to the following post for details:
55+
* https://community.developer.atlassian.com/t/is-the-forge-invocation-token-contract-correct/91578/3
3156
*
32-
* @param installationId Identifier for the specific installation of an app. This is the value that any remote storage should be keyed against.
33-
* @param apiBaseUrl Base URL where all product API requests should be routed
34-
* @param id Forge application ID matching the value in the Forge manifest.yml
35-
* @param appVersion Forge application version being invoked
36-
* @param environment Information about the environment the app is running in
37-
* @param module Information about the module that initiated this remote call
38-
* @param license Information about the license associated with the app. This field is only present for paid apps in the production environment.
57+
* @param isActive
58+
* Specifies if the license is active.
3959
*/
40-
final case class App(installationId: String,
41-
apiBaseUrl: String,
42-
id: String,
43-
appVersion: String,
44-
environment: Environment,
45-
module: Module,
46-
license: Option[License])
47-
48-
/**
49-
* Forge invocation context represents the payload included in the Forge Invocation Token (FIT).
60+
final case class License(isActive: Boolean)
61+
62+
/** @param installationId
63+
* Identifier for the specific installation of an app. This is the value that
64+
* any remote storage should be keyed against.
65+
* @param apiBaseUrl
66+
* Base URL where all product API requests should be routed
67+
* @param id
68+
* Forge application ID matching the value in the Forge manifest.yml
69+
* @param appVersion
70+
* Forge application version being invoked
71+
* @param environment
72+
* Information about the environment the app is running in
73+
* @param module
74+
* Information about the module that initiated this remote call
75+
* @param installation
76+
* Information about app installations
77+
* @param license
78+
* Information about the license associated with the app. This field is only
79+
* present for paid apps in the production environment.
80+
*/
81+
final case class App(
82+
installationId: String,
83+
apiBaseUrl: String,
84+
id: String,
85+
appVersion: String,
86+
environment: Environment,
87+
module: Module,
88+
installation: Installation,
89+
license: Option[License]
90+
)
91+
92+
/** Forge invocation context represents the payload included in the Forge
93+
* Invocation Token (FIT).
5094
*
51-
* The FIT payload includes details about the invocation context of a Forge Remote call.
95+
* The FIT payload includes details about the invocation context of a Forge
96+
* Remote call.
5297
*
53-
* @param app Details about the app and installation context
54-
* @param context Context depending on how the app is using Forge Remote
55-
* @param principal ID of the user who invoked the app. UI modules only
98+
* @param app
99+
* Details about the app and installation context
100+
* @param context
101+
* Context depending on how the app is using Forge Remote
102+
* @param principal
103+
* ID of the user who invoked the app. UI modules only
56104
*
57-
* @see https://developer.atlassian.com/platform/forge/forge-remote-overview/#the-forge-invocation-token--fit-
105+
* @see
106+
* https://developer.atlassian.com/platform/forge/forge-remote-overview/#the-forge-invocation-token--fit-
58107
*/
59-
final case class ForgeInvocationContext(app: App,
60-
context: Option[JsonObject],
61-
principal: Option[String])
62-
extends SecurityContext
108+
final case class ForgeInvocationContext(
109+
app: App,
110+
context: Option[JsonObject],
111+
principal: Option[String]
112+
) extends SecurityContext

modules/core/app/io/toolsplus/atlassian/connect/play/auth/frc/jwt/ForgeRemoteJWKSourceProvider.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ class ForgeRemoteJWKSourceProvider @Inject()(
4242
object ForgeRemoteJWKSourceProvider {
4343
private val remoteJWKSetHttpConnectTimeoutMs: Int = 1000
4444
private val remoteJWKSetHttpReadTimeoutMs: Int = 1000
45-
private val remoteJWKSetHttpEntitySizeLimitByte: Int = 100 * 1024
45+
// Setting the value to zero means infinite
46+
private val remoteJWKSetHttpEntitySizeLimitByte: Int = 0
4647
}

modules/core/app/io/toolsplus/atlassian/connect/play/models/AtlassianForgeProperties.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class AtlassianForgeProperties @Inject()(config: Configuration) {
2020
lazy val forgeRemoteJWKSetProductionUrl: String =
2121
atlassianForgeConfig.get[String]("remote.jwkSetProductionUrl")
2222

23+
lazy val systemAccessTokenExpiryLeewayMs: Int =
24+
atlassianForgeConfig.get[Int]("systemAccessTokenExpiryLeewayMs")
25+
2326
private lazy val atlassianForgeConfig =
2427
config.get[Configuration]("atlassian.forge")
2528

modules/core/conf/evolutions/default/1.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ CREATE UNIQUE INDEX uq_forge_installation_installation_id
3333
ALTER TABLE forge_installation
3434
ADD CONSTRAINT fk_forge_installation_atlassian_host FOREIGN KEY (client_key) REFERENCES atlassian_host (client_key) ON UPDATE CASCADE ON DELETE CASCADE;
3535

36+
CREATE TABLE forge_system_access_token
37+
(
38+
installation_id VARCHAR PRIMARY KEY,
39+
api_base_url VARCHAR NOT NULL,
40+
access_token VARCHAR NOT NULL,
41+
expiration_time TIMESTAMP NOT NULL
42+
);
43+
CREATE INDEX forge_system_access_token_expiration_time
44+
ON forge_system_access_token (expiration_time);
45+
3646
# --- !Downs
47+
DROP TABLE forge_system_access_token;
3748
DROP TABLE forge_installation;
3849
DROP TABLE atlassian_host;

modules/core/conf/reference.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ atlassian {
55
}
66
forge {
77
appId = "changeme" # ari:cloud:ecosystem::app/327bc6a8-d0ec-46a4-b42d-e9e5fe2350f9
8+
systemAccessTokenExpiryLeewayMs = 200
89
remote {
910
jwkSetStagingUrl = "https://forge.cdn.stg.atlassian-dev.net/.well-known/jwks.json"
1011
jwkSetProductionUrl = "https://forge.cdn.prod.atlassian-dev.net/.well-known/jwks.json"

0 commit comments

Comments
 (0)