diff --git a/package-lock.json b/package-lock.json index 8c04c8869f..e87becd2bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "@junobuild/cli-tools": "^0.10.1", "@junobuild/config": "^2.10.1", "@junobuild/config-loader": "^0.4.8", - "@junobuild/errors": "^0.2.2", + "@junobuild/errors": "^0.2.2-next-2026-02-12", "@junobuild/functions": "^0.5.6", "@ltd/j-toml": "^1.38.0", "@playwright/test": "^1.57.0", @@ -1816,9 +1816,9 @@ } }, "node_modules/@junobuild/errors": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@junobuild/errors/-/errors-0.2.2.tgz", - "integrity": "sha512-a/F8/M41HMXBhMeNMWZMElB9t+yLu1fOJ8srS8IDwPqdzSbB3ioZ0HmdrVxkvjDxQcx+lt1BHsJA+/E8YW+5Rw==", + "version": "0.2.2-next-2026-02-12", + "resolved": "https://registry.npmjs.org/@junobuild/errors/-/errors-0.2.2-next-2026-02-12.tgz", + "integrity": "sha512-XjZ25Cb/zUDRz0rM+zgd7XZ/aQ1jES6o24xHlBmb5RNttU01tQAKvbpuvw83EYPDIbbUQ6SFWnsZT1qNjy3VSQ==", "license": "MIT" }, "node_modules/@junobuild/functions": { @@ -2231,7 +2231,6 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.57.0" }, diff --git a/package.json b/package.json index 86e1c1b04c..1bff502f6d 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@junobuild/cli-tools": "^0.10.1", "@junobuild/config": "^2.10.1", "@junobuild/config-loader": "^0.4.8", - "@junobuild/errors": "^0.2.2", + "@junobuild/errors": "^0.2.2-next-2026-02-12", "@junobuild/functions": "^0.5.6", "@ltd/j-toml": "^1.38.0", "@playwright/test": "^1.57.0", diff --git a/src/console/console.did b/src/console/console.did index 7e0d216c08..d22c37bee7 100644 --- a/src/console/console.did +++ b/src/console/console.did @@ -123,6 +123,7 @@ type GetDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type GetOrRefreshJwksError = variant { InvalidConfig : text; @@ -262,6 +263,7 @@ type PrepareDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type PreparedDelegation = record { user_key : blob; expiration : nat64 }; type Proposal = record { diff --git a/src/declarations/console/console.did.d.ts b/src/declarations/console/console.did.d.ts index 3c7686e3e3..e29aa6ea18 100644 --- a/src/declarations/console/console.did.d.ts +++ b/src/declarations/console/console.did.d.ts @@ -161,7 +161,8 @@ export type GetDelegationError = | { NoSuchDelegation: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export type GetOrRefreshJwksError = | { InvalidConfig: string } | { MissingKid: null } @@ -319,7 +320,8 @@ export type PrepareDelegationError = | { GetCachedJwks: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export interface PreparedDelegation { user_key: Uint8Array; expiration: bigint; diff --git a/src/declarations/console/console.factory.certified.did.js b/src/declarations/console/console.factory.certified.did.js index 34f064b66d..ba2e7aaa7f 100644 --- a/src/declarations/console/console.factory.certified.did.js +++ b/src/declarations/console/console.factory.certified.did.js @@ -85,7 +85,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -215,7 +216,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const Result_1 = IDL.Variant({ Ok: SignedDelegation, diff --git a/src/declarations/console/console.factory.did.js b/src/declarations/console/console.factory.did.js index 3df79d40aa..7a4362d539 100644 --- a/src/declarations/console/console.factory.did.js +++ b/src/declarations/console/console.factory.did.js @@ -85,7 +85,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -215,7 +216,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const Result_1 = IDL.Variant({ Ok: SignedDelegation, diff --git a/src/declarations/console/console.factory.did.mjs b/src/declarations/console/console.factory.did.mjs index 3df79d40aa..7a4362d539 100644 --- a/src/declarations/console/console.factory.did.mjs +++ b/src/declarations/console/console.factory.did.mjs @@ -85,7 +85,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -215,7 +216,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const Result_1 = IDL.Variant({ Ok: SignedDelegation, diff --git a/src/declarations/satellite/satellite.did.d.ts b/src/declarations/satellite/satellite.did.d.ts index 132e96c9d2..74f69fa878 100644 --- a/src/declarations/satellite/satellite.did.d.ts +++ b/src/declarations/satellite/satellite.did.d.ts @@ -34,12 +34,27 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateAutomationArgs = { + OpenId: OpenIdPrepareAutomationArgs; +}; +export type AuthenticateAutomationResultResponse = + | { + Ok: [Principal, AutomationController]; + } + | { Err: AuthenticationAutomationError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; delegation: PreparedDelegation; } export type AuthenticationArgs = { OpenId: OpenIdPrepareDelegationArgs }; +export type AuthenticationAutomationError = + | { + PrepareAutomation: PrepareAutomationError; + } + | { RegisterController: string } + | { SaveWorkflowMetadata: string } + | { SaveUniqueJtiToken: string }; export interface AuthenticationConfig { updated_at: [] | [bigint]; openid: [] | [AuthenticationConfigOpenId]; @@ -74,6 +89,10 @@ export interface AutomationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdAutomationProvider, OpenIdAutomationProviderConfig]>; } +export interface AutomationController { + scope: AutomationScope; + expires_at: bigint; +} export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { @@ -153,7 +172,8 @@ export type GetDelegationError = | { NoSuchDelegation: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export type GetDelegationResultResponse = { Ok: SignedDelegation } | { Err: GetDelegationError }; export type GetOrRefreshJwksError = | { InvalidConfig: string } @@ -300,6 +320,10 @@ export interface OpenIdGetDelegationArgs { salt: Uint8Array; expiration: bigint; } +export interface OpenIdPrepareAutomationArgs { + jwt: string; + salt: Uint8Array; +} export interface OpenIdPrepareDelegationArgs { jwt: string; session_key: Uint8Array; @@ -310,6 +334,17 @@ export type Permission = | { Private: null } | { Public: null } | { Managed: null }; +export type PrepareAutomationError = + | { + JwtFindProvider: JwtFindProviderError; + } + | { InvalidController: string } + | { GetCachedJwks: null } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError } + | { ControllerAlreadyExists: null } + | { InvalidObservatoryId: string } + | { TooManyControllers: string }; export type PrepareDelegationError = | { JwtFindProvider: JwtFindProviderError; @@ -317,7 +352,8 @@ export type PrepareDelegationError = | { GetCachedJwks: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export interface PreparedDelegation { user_key: Uint8Array; expiration: bigint; @@ -475,6 +511,10 @@ export interface UploadChunkResult { } export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_automation: ActorMethod< + [AuthenticateAutomationArgs], + AuthenticateAutomationResultResponse + >; commit_asset_upload: ActorMethod<[CommitBatch], undefined>; commit_proposal: ActorMethod<[CommitProposal], null>; commit_proposal_asset_upload: ActorMethod<[CommitBatch], undefined>; diff --git a/src/declarations/satellite/satellite.factory.certified.did.js b/src/declarations/satellite/satellite.factory.certified.did.js index ace9b4f050..5f8bb42208 100644 --- a/src/declarations/satellite/satellite.factory.certified.did.js +++ b/src/declarations/satellite/satellite.factory.certified.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -484,6 +517,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/declarations/satellite/satellite.factory.did.js b/src/declarations/satellite/satellite.factory.did.js index 1bd8897a0b..2c40f544dd 100644 --- a/src/declarations/satellite/satellite.factory.did.js +++ b/src/declarations/satellite/satellite.factory.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -484,6 +517,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/declarations/satellite/satellite.factory.did.mjs b/src/declarations/satellite/satellite.factory.did.mjs index 1bd8897a0b..2c40f544dd 100644 --- a/src/declarations/satellite/satellite.factory.did.mjs +++ b/src/declarations/satellite/satellite.factory.did.mjs @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -484,6 +517,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/declarations/sputnik/sputnik.did.d.ts b/src/declarations/sputnik/sputnik.did.d.ts index 132e96c9d2..74f69fa878 100644 --- a/src/declarations/sputnik/sputnik.did.d.ts +++ b/src/declarations/sputnik/sputnik.did.d.ts @@ -34,12 +34,27 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateAutomationArgs = { + OpenId: OpenIdPrepareAutomationArgs; +}; +export type AuthenticateAutomationResultResponse = + | { + Ok: [Principal, AutomationController]; + } + | { Err: AuthenticationAutomationError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; delegation: PreparedDelegation; } export type AuthenticationArgs = { OpenId: OpenIdPrepareDelegationArgs }; +export type AuthenticationAutomationError = + | { + PrepareAutomation: PrepareAutomationError; + } + | { RegisterController: string } + | { SaveWorkflowMetadata: string } + | { SaveUniqueJtiToken: string }; export interface AuthenticationConfig { updated_at: [] | [bigint]; openid: [] | [AuthenticationConfigOpenId]; @@ -74,6 +89,10 @@ export interface AutomationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdAutomationProvider, OpenIdAutomationProviderConfig]>; } +export interface AutomationController { + scope: AutomationScope; + expires_at: bigint; +} export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { @@ -153,7 +172,8 @@ export type GetDelegationError = | { NoSuchDelegation: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export type GetDelegationResultResponse = { Ok: SignedDelegation } | { Err: GetDelegationError }; export type GetOrRefreshJwksError = | { InvalidConfig: string } @@ -300,6 +320,10 @@ export interface OpenIdGetDelegationArgs { salt: Uint8Array; expiration: bigint; } +export interface OpenIdPrepareAutomationArgs { + jwt: string; + salt: Uint8Array; +} export interface OpenIdPrepareDelegationArgs { jwt: string; session_key: Uint8Array; @@ -310,6 +334,17 @@ export type Permission = | { Private: null } | { Public: null } | { Managed: null }; +export type PrepareAutomationError = + | { + JwtFindProvider: JwtFindProviderError; + } + | { InvalidController: string } + | { GetCachedJwks: null } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError } + | { ControllerAlreadyExists: null } + | { InvalidObservatoryId: string } + | { TooManyControllers: string }; export type PrepareDelegationError = | { JwtFindProvider: JwtFindProviderError; @@ -317,7 +352,8 @@ export type PrepareDelegationError = | { GetCachedJwks: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export interface PreparedDelegation { user_key: Uint8Array; expiration: bigint; @@ -475,6 +511,10 @@ export interface UploadChunkResult { } export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_automation: ActorMethod< + [AuthenticateAutomationArgs], + AuthenticateAutomationResultResponse + >; commit_asset_upload: ActorMethod<[CommitBatch], undefined>; commit_proposal: ActorMethod<[CommitProposal], null>; commit_proposal_asset_upload: ActorMethod<[CommitBatch], undefined>; diff --git a/src/declarations/sputnik/sputnik.factory.certified.did.js b/src/declarations/sputnik/sputnik.factory.certified.did.js index ace9b4f050..5f8bb42208 100644 --- a/src/declarations/sputnik/sputnik.factory.certified.did.js +++ b/src/declarations/sputnik/sputnik.factory.certified.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -484,6 +517,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/declarations/sputnik/sputnik.factory.did.js b/src/declarations/sputnik/sputnik.factory.did.js index 1bd8897a0b..2c40f544dd 100644 --- a/src/declarations/sputnik/sputnik.factory.did.js +++ b/src/declarations/sputnik/sputnik.factory.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -484,6 +517,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/libs/auth/src/automation/constants.rs b/src/libs/auth/src/automation/constants.rs new file mode 100644 index 0000000000..d027c00359 --- /dev/null +++ b/src/libs/auth/src/automation/constants.rs @@ -0,0 +1,7 @@ +const MINUTE_NS: u64 = 60 * 1_000_000_000; + +// 10 minutes in nanoseconds +pub const DEFAULT_EXPIRATION_PERIOD_NS: u64 = 10 * MINUTE_NS; + +// The maximum duration for a automation controller +pub const MAX_EXPIRATION_PERIOD_NS: u64 = 60 * MINUTE_NS; diff --git a/src/libs/auth/src/automation/impls.rs b/src/libs/auth/src/automation/impls.rs new file mode 100644 index 0000000000..eed3933c05 --- /dev/null +++ b/src/libs/auth/src/automation/impls.rs @@ -0,0 +1,30 @@ +use crate::automation::types::{AutomationScope, PrepareAutomationError}; +use crate::openid::credentials::types::errors::VerifyOpenidCredentialsError; +use junobuild_shared::types::state::ControllerScope; + +impl From for PrepareAutomationError { + fn from(e: VerifyOpenidCredentialsError) -> Self { + match e { + VerifyOpenidCredentialsError::InvalidObservatoryId(err) => { + PrepareAutomationError::InvalidObservatoryId(err) + } + VerifyOpenidCredentialsError::GetOrFetchJwks(err) => { + PrepareAutomationError::GetOrFetchJwks(err) + } + VerifyOpenidCredentialsError::GetCachedJwks => PrepareAutomationError::GetCachedJwks, + VerifyOpenidCredentialsError::JwtFindProvider(err) => { + PrepareAutomationError::JwtFindProvider(err) + } + VerifyOpenidCredentialsError::JwtVerify(err) => PrepareAutomationError::JwtVerify(err), + } + } +} + +impl From for ControllerScope { + fn from(scope: AutomationScope) -> Self { + match scope { + AutomationScope::Write => ControllerScope::Write, + AutomationScope::Submit => ControllerScope::Submit, + } + } +} diff --git a/src/libs/auth/src/automation/mod.rs b/src/libs/auth/src/automation/mod.rs index cd408564ea..4db9a9240a 100644 --- a/src/libs/auth/src/automation/mod.rs +++ b/src/libs/auth/src/automation/mod.rs @@ -1 +1,7 @@ +mod constants; +mod impls; +mod prepare; pub mod types; +mod utils; + +pub use prepare::*; diff --git a/src/libs/auth/src/automation/prepare.rs b/src/libs/auth/src/automation/prepare.rs new file mode 100644 index 0000000000..b122c73cfe --- /dev/null +++ b/src/libs/auth/src/automation/prepare.rs @@ -0,0 +1,47 @@ +use crate::automation::types::{ + AutomationController, PrepareAutomationError, PrepareAutomationResult, PreparedAutomation, +}; +use crate::automation::utils::duration::build_expiration; +use crate::automation::utils::scope::build_scope; +use crate::openid::types::provider::OpenIdAutomationProvider; +use crate::strategies::{AuthAutomationStrategy, AuthHeapStrategy}; +use junobuild_shared::ic::api::caller; +use junobuild_shared::segments::controllers::{ + assert_controllers, assert_max_number_of_controllers, +}; +use junobuild_shared::types::state::ControllerId; + +pub fn openid_prepare_automation( + provider: &OpenIdAutomationProvider, + auth_heap: &impl AuthHeapStrategy, + auth_automation: &impl AuthAutomationStrategy, +) -> PrepareAutomationResult { + let controller_id = caller(); + + let existing_controllers = auth_automation.get_controllers(); + + if existing_controllers.contains_key(&controller_id) { + return Err(PrepareAutomationError::ControllerAlreadyExists); + } + + let submitted_controllers: [ControllerId; 1] = [controller_id]; + + assert_controllers(&submitted_controllers) + .map_err(PrepareAutomationError::InvalidController)?; + + let scope = build_scope(provider, auth_heap); + + assert_max_number_of_controllers( + &existing_controllers, + &submitted_controllers, + &scope.clone().into(), + None, + ) + .map_err(PrepareAutomationError::TooManyControllers)?; + + let expires_at = build_expiration(provider, auth_heap); + + let controller: AutomationController = AutomationController { expires_at, scope }; + + Ok(PreparedAutomation(controller_id, controller)) +} diff --git a/src/libs/auth/src/automation/types.rs b/src/libs/auth/src/automation/types.rs index 626195bb68..e9343711c5 100644 --- a/src/libs/auth/src/automation/types.rs +++ b/src/libs/auth/src/automation/types.rs @@ -1,8 +1,41 @@ +use crate::openid::jwkset::types::errors::GetOrRefreshJwksError; +use crate::openid::jwt::types::errors::{JwtFindProviderError, JwtVerifyError}; +use crate::state::types::state::Salt; use candid::{CandidType, Deserialize}; +use junobuild_shared::types::state::ControllerId; use serde::Serialize; +#[derive(CandidType, Serialize, Deserialize)] +pub struct OpenIdPrepareAutomationArgs { + pub jwt: String, + pub salt: Salt, +} + +pub type PrepareAutomationResult = Result; + +#[derive(CandidType, Serialize, Deserialize)] +pub struct PreparedAutomation(pub ControllerId, pub AutomationController); + +#[derive(CandidType, Serialize, Deserialize)] +pub struct AutomationController { + pub scope: AutomationScope, + pub expires_at: u64, +} + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] pub enum AutomationScope { Write, Submit, } + +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum PrepareAutomationError { + ControllerAlreadyExists, + InvalidController(String), + TooManyControllers(String), + InvalidObservatoryId(String), + GetOrFetchJwks(GetOrRefreshJwksError), + GetCachedJwks, + JwtFindProvider(JwtFindProviderError), + JwtVerify(JwtVerifyError), +} diff --git a/src/libs/auth/src/automation/utils/duration.rs b/src/libs/auth/src/automation/utils/duration.rs new file mode 100644 index 0000000000..d6b3c78e4b --- /dev/null +++ b/src/libs/auth/src/automation/utils/duration.rs @@ -0,0 +1,25 @@ +use crate::automation::constants::{DEFAULT_EXPIRATION_PERIOD_NS, MAX_EXPIRATION_PERIOD_NS}; +use crate::openid::types::provider::OpenIdAutomationProvider; +use crate::state::get_automation; +use crate::strategies::AuthHeapStrategy; +use ic_cdk::api::time; +use std::cmp::min; + +pub fn build_expiration( + provider: &OpenIdAutomationProvider, + auth_heap: &impl AuthHeapStrategy, +) -> u64 { + let max_time_to_live = get_automation(auth_heap) + .as_ref() + .and_then(|automation| automation.openid.as_ref()) + .and_then(|openid| openid.providers.get(provider)) + .and_then(|openid| openid.controller.as_ref()) + .and_then(|controller| controller.max_time_to_live); + + let controller_duration = min( + max_time_to_live.unwrap_or(DEFAULT_EXPIRATION_PERIOD_NS), + MAX_EXPIRATION_PERIOD_NS, + ); + + time().saturating_add(controller_duration) +} diff --git a/src/libs/auth/src/automation/utils/mod.rs b/src/libs/auth/src/automation/utils/mod.rs new file mode 100644 index 0000000000..3f52d1de62 --- /dev/null +++ b/src/libs/auth/src/automation/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod duration; +pub mod scope; diff --git a/src/libs/auth/src/automation/utils/scope.rs b/src/libs/auth/src/automation/utils/scope.rs new file mode 100644 index 0000000000..2a5053ffc4 --- /dev/null +++ b/src/libs/auth/src/automation/utils/scope.rs @@ -0,0 +1,19 @@ +use crate::automation::types::AutomationScope; +use crate::openid::types::provider::OpenIdAutomationProvider; +use crate::state::get_automation; +use crate::strategies::AuthHeapStrategy; + +// We default to AutomationScope::Write because practically that's what most developers use. +// i.e. most developers expect their GitHub Actions build to take effect +pub fn build_scope( + provider: &OpenIdAutomationProvider, + auth_heap: &impl AuthHeapStrategy, +) -> AutomationScope { + get_automation(auth_heap) + .as_ref() + .and_then(|automation| automation.openid.as_ref()) + .and_then(|openid| openid.providers.get(provider)) + .and_then(|openid| openid.controller.as_ref()) + .and_then(|controller| controller.scope.clone()) + .unwrap_or(AutomationScope::Write) +} diff --git a/src/libs/auth/src/delegation/impls.rs b/src/libs/auth/src/delegation/impls.rs index c2aee21bf2..c260f5e7e3 100644 --- a/src/libs/auth/src/delegation/impls.rs +++ b/src/libs/auth/src/delegation/impls.rs @@ -4,6 +4,9 @@ use crate::openid::credentials::types::errors::VerifyOpenidCredentialsError; impl From for GetDelegationError { fn from(e: VerifyOpenidCredentialsError) -> Self { match e { + VerifyOpenidCredentialsError::InvalidObservatoryId(err) => { + GetDelegationError::InvalidObservatoryId(err) + } VerifyOpenidCredentialsError::GetOrFetchJwks(err) => { GetDelegationError::GetOrFetchJwks(err) } @@ -19,6 +22,9 @@ impl From for GetDelegationError { impl From for PrepareDelegationError { fn from(e: VerifyOpenidCredentialsError) -> Self { match e { + VerifyOpenidCredentialsError::InvalidObservatoryId(err) => { + PrepareDelegationError::InvalidObservatoryId(err) + } VerifyOpenidCredentialsError::GetOrFetchJwks(err) => { PrepareDelegationError::GetOrFetchJwks(err) } diff --git a/src/libs/auth/src/delegation/types.rs b/src/libs/auth/src/delegation/types.rs index f137a132f4..de3b77dafe 100644 --- a/src/libs/auth/src/delegation/types.rs +++ b/src/libs/auth/src/delegation/types.rs @@ -52,6 +52,7 @@ pub struct Delegation { #[derive(CandidType, Serialize, Deserialize, Debug)] pub enum PrepareDelegationError { + InvalidObservatoryId(String), DeriveSeedFailed(String), GetOrFetchJwks(GetOrRefreshJwksError), GetCachedJwks, @@ -61,6 +62,7 @@ pub enum PrepareDelegationError { #[derive(CandidType, Serialize, Deserialize, Debug)] pub enum GetDelegationError { + InvalidObservatoryId(String), NoSuchDelegation, DeriveSeedFailed(String), GetOrFetchJwks(GetOrRefreshJwksError), diff --git a/src/libs/auth/src/openid/credentials/automation/impls.rs b/src/libs/auth/src/openid/credentials/automation/impls.rs new file mode 100644 index 0000000000..a95d4fb612 --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/impls.rs @@ -0,0 +1,32 @@ +use crate::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use crate::openid::credentials::automation::types::token::AutomationClaims; +use crate::openid::jwt::types::token::JwtClaims; +use jsonwebtoken::TokenData; + +impl From> for OpenIdAutomationCredential { + fn from(token: TokenData) -> Self { + Self { + sub: token.claims.sub, + iss: token.claims.iss, + jti: token.claims.jti, + repository: token.claims.repository, + repository_owner: token.claims.repository_owner, + r#ref: token.claims.r#ref, + run_id: token.claims.run_id, + run_number: token.claims.run_number, + run_attempt: token.claims.run_attempt, + } + } +} + +impl JwtClaims for AutomationClaims { + fn iat(&self) -> Option { + self.iat + } + + // We use the audience to match the caller's principal + salt because GitHub does not allow customizing + // other JWT fields, making audience our only option for binding the JWT to a specific principal. + fn nonce(&self) -> Option<&str> { + Some(&self.aud) + } +} diff --git a/src/libs/auth/src/openid/credentials/automation/mod.rs b/src/libs/auth/src/openid/credentials/automation/mod.rs new file mode 100644 index 0000000000..9d1799239b --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/mod.rs @@ -0,0 +1,6 @@ +mod impls; +pub mod types; +mod utils; +mod verify; + +pub use verify::*; diff --git a/src/libs/auth/src/openid/credentials/automation/types.rs b/src/libs/auth/src/openid/credentials/automation/types.rs new file mode 100644 index 0000000000..9dc9997d0f --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/types.rs @@ -0,0 +1,39 @@ +pub mod interface { + #[derive(Debug)] + pub struct OpenIdAutomationCredential { + pub iss: String, + pub sub: String, + pub jti: Option, + + // See https://docs.github.com/en/actions/concepts/security/openid-connect#understanding-the-oidc-token + pub repository: Option, // "octo-org/octo-repo" + pub repository_owner: Option, // "octo-org" + pub r#ref: Option, // "refs/heads/main" + pub run_id: Option, // "example-run-id" + pub run_number: Option, // 10" + pub run_attempt: Option, // "2" + } +} + +pub(crate) mod token { + use candid::Deserialize; + use serde::Serialize; + + #[derive(Debug, Clone, Deserialize, Serialize)] + pub struct AutomationClaims { + pub iss: String, + pub sub: String, + pub aud: String, + pub exp: Option, + pub nbf: Option, + pub iat: Option, + pub jti: Option, + + pub repository: Option, + pub repository_owner: Option, + pub r#ref: Option, + pub run_id: Option, + pub run_number: Option, + pub run_attempt: Option, + } +} diff --git a/src/libs/auth/src/openid/credentials/automation/utils/mod.rs b/src/libs/auth/src/openid/credentials/automation/utils/mod.rs new file mode 100644 index 0000000000..3e7c053bef --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/utils/mod.rs @@ -0,0 +1 @@ +pub mod targets; diff --git a/src/libs/auth/src/openid/credentials/automation/utils/targets.rs b/src/libs/auth/src/openid/credentials/automation/utils/targets.rs new file mode 100644 index 0000000000..0a6dcf2378 --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/utils/targets.rs @@ -0,0 +1,18 @@ +use crate::state::get_automation; +use crate::strategies::AuthHeapStrategy; +use candid::Principal; +use junobuild_shared::env::OBSERVATORY; + +pub fn target_observatory_id(auth_heap: &impl AuthHeapStrategy) -> Result { + let observatory_id = get_automation(auth_heap).as_ref().and_then(|config| { + config + .openid + .as_ref() + .and_then(|openid| openid.observatory_id) + }); + + let target = + observatory_id.unwrap_or(Principal::from_text(OBSERVATORY).map_err(|e| e.to_string())?); + + Ok(target) +} diff --git a/src/libs/auth/src/openid/credentials/automation/verify.rs b/src/libs/auth/src/openid/credentials/automation/verify.rs new file mode 100644 index 0000000000..04b26c4971 --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/verify.rs @@ -0,0 +1,438 @@ +use crate::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use crate::openid::credentials::automation::types::token::AutomationClaims; +use crate::openid::credentials::automation::utils::targets::target_observatory_id; +use crate::openid::credentials::types::errors::VerifyOpenidCredentialsError; +use crate::openid::jwkset::get_or_refresh_jwks; +use crate::openid::jwt::types::cert::Jwks; +use crate::openid::jwt::types::errors::JwtVerifyError; +use crate::openid::jwt::{unsafe_find_jwt_provider, verify_openid_jwt}; +use crate::openid::types::provider::{OpenIdAutomationProvider, OpenIdProvider}; +use crate::state::types::automation::{ + OpenIdAutomationProviderConfig, OpenIdAutomationProviders, RepositoryKey, +}; +use crate::state::types::state::Salt; +use crate::strategies::AuthHeapStrategy; + +type VerifyOpenIdAutomationCredentialsResult = + Result<(OpenIdAutomationCredential, OpenIdAutomationProvider), VerifyOpenidCredentialsError>; + +/// Verifies automation OIDC credentials (e.g. GitHub Actions) and returns the credential. +/// +/// ⚠️ **Warning:** This function does NOT enforce replay protection via JTI tracking. +/// +/// The caller MUST implement a replay protection. For example: +/// - Checking if the `jti` claim has been used before +/// - Storing the `jti` after successful verification +/// - Rejecting tokens with duplicate `jti` values +/// +/// In the Satellite implementation, this is handled by `save_unique_token_jti()`. +pub async fn verify_openid_credentials_with_jwks_renewal( + jwt: &str, + salt: &Salt, + providers: &OpenIdAutomationProviders, + auth_heap: &impl AuthHeapStrategy, +) -> VerifyOpenIdAutomationCredentialsResult { + let (automation_provider, config) = unsafe_find_jwt_provider(providers, jwt) + .map_err(VerifyOpenidCredentialsError::JwtFindProvider)?; + + let provider: OpenIdProvider = (&automation_provider).into(); + + let observatory_id = target_observatory_id(auth_heap) + .map_err(VerifyOpenidCredentialsError::InvalidObservatoryId)?; + + let jwks = get_or_refresh_jwks(&provider, jwt, observatory_id, auth_heap) + .await + .map_err(VerifyOpenidCredentialsError::GetOrFetchJwks)?; + + verify_openid_credentials(jwt, &jwks, &automation_provider, config, salt) +} + +fn verify_openid_credentials( + jwt: &str, + jwks: &Jwks, + provider: &OpenIdAutomationProvider, + config: &OpenIdAutomationProviderConfig, + salt: &Salt, +) -> VerifyOpenIdAutomationCredentialsResult { + let assert_repository = |claims: &AutomationClaims| -> Result<(), JwtVerifyError> { + let repository = claims + .repository + .as_ref() + .ok_or_else(|| JwtVerifyError::BadClaim("repository".to_string()))?; + + let parts: Vec<&str> = repository.split('/').collect(); + if parts.len() != 2 { + return Err(JwtVerifyError::BadClaim("repository_format".to_string())); + } + + let repo_key = RepositoryKey { + owner: parts[0].to_string(), + name: parts[1].to_string(), + }; + + let repo_config = config + .repositories + .get(&repo_key) + .ok_or_else(|| JwtVerifyError::BadClaim("repository_unauthorized".to_string()))?; + + if let Some(allowed_branches) = &repo_config.branches { + let ref_claim = claims + .r#ref + .as_ref() + .ok_or_else(|| JwtVerifyError::BadClaim("ref".to_string()))?; + + // ref is like "refs/heads/main", extract branch name + let branch = ref_claim + .strip_prefix("refs/heads/") + .ok_or_else(|| JwtVerifyError::BadClaim("ref_format".to_string()))?; + + if !allowed_branches.contains(&branch.to_string()) { + return Err(JwtVerifyError::BadClaim("branch_unauthorized".to_string())); + } + } + + Ok(()) + }; + + let token = verify_openid_jwt( + jwt, + provider.issuers(), + &jwks.keys, + &salt, + assert_repository, + ) + .map_err(VerifyOpenidCredentialsError::JwtVerify)?; + + let credential = OpenIdAutomationCredential::from(token); + + Ok((credential, provider.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openid::jwt::types::cert::{Jwk, JwkParams, JwkParamsRsa, JwkType, Jwks}; + use crate::openid::types::provider::OpenIdAutomationProvider; + use crate::openid::utils::nonce::build_nonce; + use crate::state::types::automation::{ + OpenIdAutomationProviderConfig, OpenIdAutomationRepositories, + OpenIdAutomationRepositoryConfig, RepositoryKey, + }; + use crate::state::types::state::Salt; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use std::collections::HashMap; + use std::time::{SystemTime, UNIX_EPOCH}; + + const TEST_RSA_PEM: &str = include_str!("../../../../tests/keys/test_rsa.pem"); + const N_B64URL: &str = "qtQHkWpyd489-_bWjRtrvlQX9CwiQreOsi6kNeeySznI8u-8sxyuO3spW1r2pRmu-rc4jnD9vY6eTGZ3WFNIMxe1geXsF_3nQc5fcNJUUZj19BZE4Ud3dCmUQ4ezkslTvBj8RgD-iBJL7BT7YpxpPgvmqQy_9IgYUkDW4I9_e6kME5kVpySvpRznlk73PfAaDkHWmUTN0j2WcxkW09SGJ_f-tStaYXtc4uH5J-PWMRjwsfL66A_sxLxAwUODJ0VUbeDxVFHGJa0L-58_6GYDTqeel1vH4XjezDL8lf53YRyva3aFxGrC_JeLuIUaJOJX1hXWQb2DruB4hVcQX9afrQ"; + const E_B64URL: &str = "AQAB"; + const KID: &str = "test-kid"; + const ISS_GITHUB_ACTIONS: &str = "https://token.actions.githubusercontent.com"; + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn test_salt() -> Salt { + [42u8; 32] + } + + fn test_jwks() -> Jwks { + Jwks { + keys: vec![Jwk { + kty: JwkType::Rsa, + alg: Some("RS256".into()), + kid: Some(KID.into()), + params: JwkParams::Rsa(JwkParamsRsa { + n: N_B64URL.into(), + e: E_B64URL.into(), + }), + }], + } + } + + fn test_config() -> OpenIdAutomationProviderConfig { + let mut repositories: OpenIdAutomationRepositories = HashMap::new(); + + repositories.insert( + RepositoryKey { + owner: "octo-org".to_string(), + name: "octo-repo".to_string(), + }, + OpenIdAutomationRepositoryConfig { + branches: Some(vec!["main".to_string(), "develop".to_string()]), + }, + ); + + OpenIdAutomationProviderConfig { + repositories, + controller: None, + } + } + + fn create_token(claims: &AutomationClaims) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KID.into()); + header.typ = Some("JWT".into()); + + let key = EncodingKey::from_rsa_pem(TEST_RSA_PEM.as_bytes()).unwrap(); + encode(&header, claims, &key).unwrap() + } + + #[test] + fn verifies_valid_automation_credentials() { + let now = now_secs(); + let salt = test_salt(); + let nonce = build_nonce(&salt); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/main".into(), + aud: nonce.clone(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: Some("octo-org/octo-repo".into()), + repository_owner: Some("octo-org".into()), + r#ref: Some("refs/heads/main".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + let config = test_config(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_ok()); + let (credential, provider) = result.unwrap(); + assert_eq!(provider, OpenIdAutomationProvider::GitHub); + assert_eq!(credential.repository.as_deref(), Some("octo-org/octo-repo")); + assert_eq!(credential.r#ref.as_deref(), Some("refs/heads/main")); + } + + #[test] + fn rejects_mismatched_audience() { + let now = now_secs(); + let salt = test_salt(); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/main".into(), + aud: "wrong-nonce".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: Some("octo-org/octo-repo".into()), + repository_owner: Some("octo-org".into()), + r#ref: Some("refs/heads/main".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + let config = test_config(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + VerifyOpenidCredentialsError::JwtVerify(JwtVerifyError::BadClaim(ref c)) if c == "nonce" + )); + } + + #[test] + fn rejects_unauthorized_repository() { + let now = now_secs(); + let salt = test_salt(); + let nonce = build_nonce(&salt); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:other-org/other-repo:ref:refs/heads/main".into(), + aud: nonce, + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: Some("other-org/other-repo".into()), + repository_owner: Some("other-org".into()), + r#ref: Some("refs/heads/main".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + let config = test_config(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + VerifyOpenidCredentialsError::JwtVerify(JwtVerifyError::BadClaim(ref c)) if c == "repository_unauthorized" + )); + } + + #[test] + fn rejects_unauthorized_branch() { + let now = now_secs(); + let salt = test_salt(); + let nonce = build_nonce(&salt); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/feature".into(), + aud: nonce, + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: Some("octo-org/octo-repo".into()), + repository_owner: Some("octo-org".into()), + r#ref: Some("refs/heads/feature".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + let config = test_config(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + VerifyOpenidCredentialsError::JwtVerify(JwtVerifyError::BadClaim(ref c)) if c == "branch_unauthorized" + )); + } + + #[test] + fn allows_all_branches_when_not_configured() { + let now = now_secs(); + let salt = test_salt(); + let nonce = build_nonce(&salt); + + let mut repositories: OpenIdAutomationRepositories = HashMap::new(); + repositories.insert( + RepositoryKey { + owner: "octo-org".to_string(), + name: "octo-repo".to_string(), + }, + OpenIdAutomationRepositoryConfig { branches: None }, + ); + + let config = OpenIdAutomationProviderConfig { + repositories, + controller: None, + }; + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/any-branch".into(), + aud: nonce, + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: Some("octo-org/octo-repo".into()), + repository_owner: Some("octo-org".into()), + r#ref: Some("refs/heads/any-branch".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_ok()); + } + + #[test] + fn rejects_missing_repository_claim() { + let now = now_secs(); + let salt = test_salt(); + let nonce = build_nonce(&salt); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/main".into(), + aud: nonce, + iat: Some(now), + exp: Some(now + 600), + nbf: None, + jti: Some("example-id".into()), + repository: None, // Missing + repository_owner: Some("octo-org".into()), + r#ref: Some("refs/heads/main".into()), + run_id: Some("123456".into()), + run_number: Some("1".into()), + run_attempt: Some("1".into()), + }; + + let jwt = create_token(&claims); + let jwks = test_jwks(); + let config = test_config(); + + let result = verify_openid_credentials( + &jwt, + &jwks, + &OpenIdAutomationProvider::GitHub, + &config, + &salt, + ); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + VerifyOpenidCredentialsError::JwtVerify(JwtVerifyError::BadClaim(ref c)) if c == "repository" + )); + } +} diff --git a/src/libs/auth/src/openid/credentials/delegation/mod.rs b/src/libs/auth/src/openid/credentials/delegation/mod.rs index 35c6668fcf..9d1799239b 100644 --- a/src/libs/auth/src/openid/credentials/delegation/mod.rs +++ b/src/libs/auth/src/openid/credentials/delegation/mod.rs @@ -1,5 +1,6 @@ mod impls; pub mod types; +mod utils; mod verify; pub use verify::*; diff --git a/src/libs/auth/src/openid/credentials/delegation/utils/mod.rs b/src/libs/auth/src/openid/credentials/delegation/utils/mod.rs new file mode 100644 index 0000000000..3e7c053bef --- /dev/null +++ b/src/libs/auth/src/openid/credentials/delegation/utils/mod.rs @@ -0,0 +1 @@ +pub mod targets; diff --git a/src/libs/auth/src/openid/jwkset/targets.rs b/src/libs/auth/src/openid/credentials/delegation/utils/targets.rs similarity index 100% rename from src/libs/auth/src/openid/jwkset/targets.rs rename to src/libs/auth/src/openid/credentials/delegation/utils/targets.rs diff --git a/src/libs/auth/src/openid/credentials/delegation/verify.rs b/src/libs/auth/src/openid/credentials/delegation/verify.rs index 59379462c8..433f2dc04e 100644 --- a/src/libs/auth/src/openid/credentials/delegation/verify.rs +++ b/src/libs/auth/src/openid/credentials/delegation/verify.rs @@ -1,5 +1,6 @@ use crate::openid::credentials::delegation::types::interface::OpenIdDelegationCredential; use crate::openid::credentials::delegation::types::token::DelegationClaims; +use crate::openid::credentials::delegation::utils::targets::target_observatory_id; use crate::openid::credentials::types::errors::VerifyOpenidCredentialsError; use crate::openid::jwkset::{get_jwks, get_or_refresh_jwks}; use crate::openid::jwt::types::cert::Jwks; @@ -28,7 +29,10 @@ pub async fn verify_openid_credentials_with_jwks_renewal( let provider: OpenIdProvider = (&delegation_provider).into(); - let jwks = get_or_refresh_jwks(&provider, jwt, auth_heap) + let observatory_id = target_observatory_id(auth_heap) + .map_err(VerifyOpenidCredentialsError::InvalidObservatoryId)?; + + let jwks = get_or_refresh_jwks(&provider, jwt, observatory_id, auth_heap) .await .map_err(VerifyOpenidCredentialsError::GetOrFetchJwks)?; diff --git a/src/libs/auth/src/openid/credentials/mod.rs b/src/libs/auth/src/openid/credentials/mod.rs index 40a7344622..6da0e24a5d 100644 --- a/src/libs/auth/src/openid/credentials/mod.rs +++ b/src/libs/auth/src/openid/credentials/mod.rs @@ -1,2 +1,3 @@ +pub mod automation; pub mod delegation; pub mod types; diff --git a/src/libs/auth/src/openid/credentials/types.rs b/src/libs/auth/src/openid/credentials/types.rs index 6eeeff198a..07065fa2a7 100644 --- a/src/libs/auth/src/openid/credentials/types.rs +++ b/src/libs/auth/src/openid/credentials/types.rs @@ -6,6 +6,7 @@ pub(crate) mod errors { #[derive(CandidType, Serialize, Deserialize, Debug)] pub enum VerifyOpenidCredentialsError { + InvalidObservatoryId(String), GetOrFetchJwks(GetOrRefreshJwksError), GetCachedJwks, JwtFindProvider(JwtFindProviderError), diff --git a/src/libs/auth/src/openid/impls.rs b/src/libs/auth/src/openid/impls.rs index 54e8387267..0b9f83073f 100644 --- a/src/libs/auth/src/openid/impls.rs +++ b/src/libs/auth/src/openid/impls.rs @@ -1,6 +1,8 @@ use crate::openid::jwt::types::cert::Jwks; use crate::openid::jwt::types::provider::JwtIssuers; -use crate::openid::types::provider::{OpenIdCertificate, OpenIdDelegationProvider, OpenIdProvider}; +use crate::openid::types::provider::{ + OpenIdAutomationProvider, OpenIdCertificate, OpenIdDelegationProvider, OpenIdProvider, +}; use junobuild_shared::data::version::next_version; use junobuild_shared::ic::api::time; use junobuild_shared::types::state::{Version, Versioned}; @@ -57,6 +59,42 @@ impl JwtIssuers for OpenIdDelegationProvider { } } +impl From<&OpenIdAutomationProvider> for OpenIdProvider { + fn from(automation_provider: &OpenIdAutomationProvider) -> Self { + match automation_provider { + OpenIdAutomationProvider::GitHub => OpenIdProvider::GitHubActions, + } + } +} + +impl OpenIdAutomationProvider { + pub fn jwks_url(&self) -> &'static str { + match self { + Self::GitHub => OpenIdProvider::GitHubActions.jwks_url(), + } + } + + pub fn issuers(&self) -> &[&'static str] { + match self { + Self::GitHub => OpenIdProvider::GitHubActions.issuers(), + } + } +} + +impl JwtIssuers for OpenIdAutomationProvider { + fn issuers(&self) -> &[&'static str] { + self.issuers() + } +} + +impl Display for OpenIdAutomationProvider { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + OpenIdAutomationProvider::GitHub => write!(f, "GitHub"), + } + } +} + impl Versioned for OpenIdCertificate { fn version(&self) -> Option { self.version @@ -177,6 +215,30 @@ mod tests { ); } + #[test] + fn test_automation_provider_to_openid_provider() { + assert_eq!( + OpenIdProvider::from(&OpenIdAutomationProvider::GitHub), + OpenIdProvider::GitHubActions + ); + } + + #[test] + fn test_automation_provider_jwks_urls() { + assert_eq!( + OpenIdAutomationProvider::GitHub.jwks_url(), + "https://token.actions.githubusercontent.com/.well-known/jwks" + ); + } + + #[test] + fn test_automation_provider_issuers() { + assert_eq!( + OpenIdAutomationProvider::GitHub.issuers(), + &["https://token.actions.githubusercontent.com"] + ); + } + #[test] fn test_openid_certificate_init() { let jwks = Jwks { keys: vec![] }; diff --git a/src/libs/auth/src/openid/jwkset/jwks.rs b/src/libs/auth/src/openid/jwkset/jwks.rs index c599fa0092..46682cfb38 100644 --- a/src/libs/auth/src/openid/jwkset/jwks.rs +++ b/src/libs/auth/src/openid/jwkset/jwks.rs @@ -1,13 +1,13 @@ use crate::openid::jwkset::asserts::refresh_allowed; use crate::openid::jwkset::asserts::types::RefreshStatus; use crate::openid::jwkset::fetch::fetch_openid_certificate; -use crate::openid::jwkset::targets::target_observatory_id; use crate::openid::jwkset::types::errors::GetOrRefreshJwksError; use crate::openid::jwt::types::cert::Jwks; use crate::openid::jwt::unsafe_find_jwt_kid; use crate::openid::types::provider::OpenIdProvider; use crate::state::{cache_certificate, get_cached_certificate, record_fetch_attempt}; use crate::strategies::AuthHeapStrategy; +use candid::Principal; pub fn get_jwks(provider: &OpenIdProvider, auth_heap: &impl AuthHeapStrategy) -> Option { let cached_certificate = get_cached_certificate(provider, auth_heap); @@ -21,6 +21,7 @@ pub fn get_jwks(provider: &OpenIdProvider, auth_heap: &impl AuthHeapStrategy) -> pub async fn get_or_refresh_jwks( provider: &OpenIdProvider, jwt: &str, + observatory_id: Principal, auth_heap: &impl AuthHeapStrategy, ) -> Result { let unsafe_kid = unsafe_find_jwt_kid(jwt)?; @@ -49,9 +50,6 @@ pub async fn get_or_refresh_jwks( } } - let observatory_id = - target_observatory_id(auth_heap).map_err(GetOrRefreshJwksError::InvalidConfig)?; - let fetched_certificate = fetch_openid_certificate(provider, observatory_id) .await .map_err(GetOrRefreshJwksError::FetchFailed)? diff --git a/src/libs/auth/src/openid/jwkset/mod.rs b/src/libs/auth/src/openid/jwkset/mod.rs index 82fe2033e5..3a499b4bdc 100644 --- a/src/libs/auth/src/openid/jwkset/mod.rs +++ b/src/libs/auth/src/openid/jwkset/mod.rs @@ -2,7 +2,6 @@ mod asserts; mod fetch; mod impls; mod jwks; -mod targets; pub mod types; pub(crate) use jwks::*; diff --git a/src/libs/auth/src/strategies.rs b/src/libs/auth/src/strategies.rs index 62e58a9768..a043e3e478 100644 --- a/src/libs/auth/src/strategies.rs +++ b/src/libs/auth/src/strategies.rs @@ -1,5 +1,6 @@ use crate::state::types::state::AuthenticationHeapState; use ic_certification::Hash; +use junobuild_shared::types::state::Controllers; pub trait AuthHeapStrategy { fn with_auth_state(&self, f: impl FnOnce(&Option) -> R) -> R; @@ -15,3 +16,7 @@ pub trait AuthCertificateStrategy { fn get_asset_hashes_root_hash(&self) -> Hash; } + +pub trait AuthAutomationStrategy { + fn get_controllers(&self) -> Controllers; +} diff --git a/src/libs/collections/src/constants/db.rs b/src/libs/collections/src/constants/db.rs index 79e29b9f00..d3dd96ac8e 100644 --- a/src/libs/collections/src/constants/db.rs +++ b/src/libs/collections/src/constants/db.rs @@ -8,6 +8,8 @@ pub const COLLECTION_LOG_KEY: &str = "#log"; pub const COLLECTION_USER_USAGE_KEY: &str = "#user-usage"; pub const COLLECTION_USER_WEBAUTHN_KEY: &str = "#user-webauthn"; pub const COLLECTION_USER_WEBAUTHN_INDEX_KEY: &str = "#user-webauthn-index"; +pub const COLLECTION_AUTOMATION_TOKEN_KEY: &str = "#automation-token"; +pub const COLLECTION_AUTOMATION_WORKFLOW_KEY: &str = "#automation-workflow"; const COLLECTION_USER_DEFAULT_RULE: SetRule = SetRule { read: Managed, @@ -76,7 +78,34 @@ pub const COLLECTION_USER_WEBAUTHN_INDEX_DEFAULT_RULE: SetRule = SetRule { rate_config: None, }; -pub const DEFAULT_DB_COLLECTIONS: [(&str, SetRule); 5] = [ +pub const COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE: SetRule = SetRule { + // Created and read through internal hooks. Write is restricted to Satellites themselves. + read: Controllers, + write: Controllers, + memory: Some(Memory::Stable), + mutable_permissions: Some(false), + max_size: None, + max_capacity: None, + max_changes_per_user: None, + version: None, + rate_config: None, +}; + +pub const COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE: SetRule = SetRule { + // Created through internal hooks. Write is restricted to Satellites themselves. + // Read allowed for controllers. + read: Controllers, + write: Controllers, + memory: Some(Memory::Stable), + mutable_permissions: Some(false), + max_size: None, + max_capacity: None, + max_changes_per_user: None, + version: None, + rate_config: None, +}; + +pub const DEFAULT_DB_COLLECTIONS: [(&str, SetRule); 7] = [ (COLLECTION_USER_KEY, COLLECTION_USER_DEFAULT_RULE), (COLLECTION_LOG_KEY, COLLECTION_LOG_DEFAULT_RULE), ( @@ -91,4 +120,12 @@ pub const DEFAULT_DB_COLLECTIONS: [(&str, SetRule); 5] = [ COLLECTION_USER_WEBAUTHN_INDEX_KEY, COLLECTION_USER_WEBAUTHN_INDEX_DEFAULT_RULE, ), + ( + COLLECTION_AUTOMATION_TOKEN_KEY, + COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE, + ), + ( + COLLECTION_AUTOMATION_WORKFLOW_KEY, + COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE, + ), ]; diff --git a/src/libs/satellite/satellite.did b/src/libs/satellite/satellite.did index 80fa48187a..7489c55b8d 100644 --- a/src/libs/satellite/satellite.did +++ b/src/libs/satellite/satellite.did @@ -20,12 +20,25 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateAutomationArgs = variant { + OpenId : OpenIdPrepareAutomationArgs; +}; +type AuthenticateAutomationResultResponse = variant { + Ok : record { principal; AutomationController }; + Err : AuthenticationAutomationError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; }; type Authentication = record { doc : Doc; delegation : PreparedDelegation }; type AuthenticationArgs = variant { OpenId : OpenIdPrepareDelegationArgs }; +type AuthenticationAutomationError = variant { + PrepareAutomation : PrepareAutomationError; + RegisterController : text; + SaveWorkflowMetadata : text; + SaveUniqueJtiToken : text; +}; type AuthenticationConfig = record { updated_at : opt nat64; openid : opt AuthenticationConfigOpenId; @@ -60,6 +73,10 @@ type AutomationConfigOpenId = record { OpenIdAutomationProviderConfig; }; }; +type AutomationController = record { + scope : AutomationScope; + expires_at : nat64; +}; type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { @@ -123,6 +140,7 @@ type GetDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type GetDelegationResultResponse = variant { Ok : SignedDelegation; @@ -252,18 +270,30 @@ type OpenIdGetDelegationArgs = record { salt : blob; expiration : nat64; }; +type OpenIdPrepareAutomationArgs = record { jwt : text; salt : blob }; type OpenIdPrepareDelegationArgs = record { jwt : text; session_key : blob; salt : blob; }; type Permission = variant { Controllers; Private; Public; Managed }; +type PrepareAutomationError = variant { + JwtFindProvider : JwtFindProviderError; + InvalidController : text; + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; + ControllerAlreadyExists; + InvalidObservatoryId : text; + TooManyControllers : text; +}; type PrepareDelegationError = variant { JwtFindProvider : JwtFindProviderError; GetCachedJwks; JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type PreparedDelegation = record { user_key : blob; expiration : nat64 }; type Proposal = record { @@ -406,6 +436,9 @@ type UploadChunk = record { type UploadChunkResult = record { chunk_id : nat }; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_automation : (AuthenticateAutomationArgs) -> ( + AuthenticateAutomationResultResponse, + ); commit_asset_upload : (CommitBatch) -> (); commit_proposal : (CommitProposal) -> (null); commit_proposal_asset_upload : (CommitBatch) -> (); diff --git a/src/libs/satellite/src/api/automation.rs b/src/libs/satellite/src/api/automation.rs new file mode 100644 index 0000000000..d46658c382 --- /dev/null +++ b/src/libs/satellite/src/api/automation.rs @@ -0,0 +1,13 @@ +use crate::automation::authenticate::openid_authenticate_automation; +use crate::automation::types::{AuthenticateAutomationArgs, AuthenticateAutomationResult}; +use junobuild_shared::ic::UnwrapOrTrap; + +pub async fn authenticate_automation( + args: AuthenticateAutomationArgs, +) -> AuthenticateAutomationResult { + match args { + AuthenticateAutomationArgs::OpenId(args) => { + openid_authenticate_automation(&args).await.unwrap_or_trap() + } + } +} diff --git a/src/libs/satellite/src/api/mod.rs b/src/libs/satellite/src/api/mod.rs index 061385cc3f..b5da645856 100644 --- a/src/libs/satellite/src/api/mod.rs +++ b/src/libs/satellite/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod automation; pub mod cdn; pub mod config; pub mod controllers; diff --git a/src/libs/satellite/src/automation/authenticate.rs b/src/libs/satellite/src/automation/authenticate.rs new file mode 100644 index 0000000000..d6c09a0048 --- /dev/null +++ b/src/libs/satellite/src/automation/authenticate.rs @@ -0,0 +1,39 @@ +use crate::auth::strategy_impls::AuthHeap; +use crate::automation::controllers::register::register_controller; +use crate::automation::prepare; +use crate::automation::token::save_unique_token_jti; +use crate::automation::types::{AuthenticateAutomationResult, AuthenticationAutomationError}; +use crate::automation::workflow::save_workflow_metadata; +use junobuild_auth::automation::types::OpenIdPrepareAutomationArgs; +use junobuild_auth::state::get_automation_providers; + +pub async fn openid_authenticate_automation( + args: &OpenIdPrepareAutomationArgs, +) -> Result { + let providers = get_automation_providers(&AuthHeap)?; + + let prepared_automation = prepare::openid_prepare_automation(args, &providers).await; + + let result = match prepared_automation { + Ok((automation, provider, credential)) => { + if let Err(err) = save_unique_token_jti(&automation, &provider, &credential) { + return Ok(Err(AuthenticationAutomationError::SaveUniqueJtiToken(err))); + } + + if let Err(err) = save_workflow_metadata(&provider, &credential) { + return Ok(Err(AuthenticationAutomationError::SaveWorkflowMetadata( + err, + ))); + } + + if let Err(err) = register_controller(&automation, &provider, &credential) { + return Ok(Err(AuthenticationAutomationError::RegisterController(err))); + } + + Ok(automation) + } + Err(err) => Err(AuthenticationAutomationError::PrepareAutomation(err)), + }; + + Ok(result) +} diff --git a/src/libs/satellite/src/automation/controllers/mod.rs b/src/libs/satellite/src/automation/controllers/mod.rs new file mode 100644 index 0000000000..f862bee245 --- /dev/null +++ b/src/libs/satellite/src/automation/controllers/mod.rs @@ -0,0 +1 @@ +pub mod register; diff --git a/src/libs/satellite/src/automation/controllers/register.rs b/src/libs/satellite/src/automation/controllers/register.rs new file mode 100644 index 0000000000..d568b6082c --- /dev/null +++ b/src/libs/satellite/src/automation/controllers/register.rs @@ -0,0 +1,33 @@ +use crate::automation::workflow::build_automation_workflow_key; +use crate::controllers::store::set_controllers; +use junobuild_auth::automation::types::PreparedAutomation; +use junobuild_auth::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_shared::types::interface::SetController; +use junobuild_shared::types::state::{ControllerId, ControllerKind, Metadata}; + +pub fn register_controller( + automation: &PreparedAutomation, + provider: &OpenIdAutomationProvider, + credential: &OpenIdAutomationCredential, +) -> Result<(), String> { + let PreparedAutomation(controller_id, controller) = automation; + + let controllers: [ControllerId; 1] = [controller_id.clone()]; + + let automation_workflow_key = build_automation_workflow_key(provider, credential)?; + + let mut metadata: Metadata = Default::default(); + metadata.insert("workflow_key".to_string(), automation_workflow_key.to_key()); + + let controller: SetController = SetController { + scope: controller.scope.clone().into(), + metadata, + expires_at: Some(controller.expires_at), + kind: Some(ControllerKind::Automation), + }; + + set_controllers(&controllers, &controller); + + Ok(()) +} diff --git a/src/libs/satellite/src/automation/mod.rs b/src/libs/satellite/src/automation/mod.rs index 55c88cbf3d..d03cd7c7cf 100644 --- a/src/libs/satellite/src/automation/mod.rs +++ b/src/libs/satellite/src/automation/mod.rs @@ -1 +1,11 @@ +pub mod authenticate; +mod controllers; +mod prepare; pub mod store; +mod strategy_impls; +mod token; +pub mod types; +mod workflow; + +pub use token::assert::*; +pub use workflow::assert::*; diff --git a/src/libs/satellite/src/automation/prepare.rs b/src/libs/satellite/src/automation/prepare.rs new file mode 100644 index 0000000000..d8d13dfb39 --- /dev/null +++ b/src/libs/satellite/src/automation/prepare.rs @@ -0,0 +1,38 @@ +use crate::auth::strategy_impls::AuthHeap; +use crate::automation::strategy_impls::AuthAutomation; +use junobuild_auth::automation; +use junobuild_auth::automation::types::{ + OpenIdPrepareAutomationArgs, PrepareAutomationError, PreparedAutomation, +}; +use junobuild_auth::openid::credentials; +use junobuild_auth::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_auth::state::types::automation::OpenIdAutomationProviders; + +pub type OpenIdPrepareAutomationResult = Result< + ( + PreparedAutomation, + OpenIdAutomationProvider, + OpenIdAutomationCredential, + ), + PrepareAutomationError, +>; + +pub async fn openid_prepare_automation( + args: &OpenIdPrepareAutomationArgs, + providers: &OpenIdAutomationProviders, +) -> OpenIdPrepareAutomationResult { + let (credential, provider) = + match credentials::automation::verify_openid_credentials_with_jwks_renewal( + &args.jwt, &args.salt, providers, &AuthHeap, + ) + .await + { + Ok(value) => value, + Err(err) => return Err(PrepareAutomationError::from(err)), + }; + + let result = automation::openid_prepare_automation(&provider, &AuthHeap, &AuthAutomation); + + result.map(|prepared_automation| (prepared_automation, provider, credential)) +} diff --git a/src/libs/satellite/src/automation/strategy_impls.rs b/src/libs/satellite/src/automation/strategy_impls.rs new file mode 100644 index 0000000000..6f16284c64 --- /dev/null +++ b/src/libs/satellite/src/automation/strategy_impls.rs @@ -0,0 +1,11 @@ +use crate::get_controllers; +use junobuild_auth::strategies::AuthAutomationStrategy; +use junobuild_shared::types::state::Controllers; + +pub struct AuthAutomation; + +impl AuthAutomationStrategy for AuthAutomation { + fn get_controllers(&self) -> Controllers { + get_controllers() + } +} diff --git a/src/libs/satellite/src/automation/token/assert.rs b/src/libs/satellite/src/automation/token/assert.rs new file mode 100644 index 0000000000..4f6c84ff57 --- /dev/null +++ b/src/libs/satellite/src/automation/token/assert.rs @@ -0,0 +1,21 @@ +use crate::errors::automation::JUNO_DATASTORE_ERROR_AUTOMATION_CALLER; +use candid::Principal; +use junobuild_collections::constants::db::COLLECTION_AUTOMATION_TOKEN_KEY; +use junobuild_collections::types::core::CollectionKey; +use junobuild_shared::ic::api::id; +use junobuild_shared::utils::principal_not_equal; + +pub fn assert_automation_token_caller( + caller: Principal, + collection: &CollectionKey, +) -> Result<(), String> { + if collection != COLLECTION_AUTOMATION_TOKEN_KEY { + return Ok(()); + } + + if principal_not_equal(id(), caller) { + return Err(JUNO_DATASTORE_ERROR_AUTOMATION_CALLER.to_string()); + } + + Ok(()) +} diff --git a/src/libs/satellite/src/automation/token/impls.rs b/src/libs/satellite/src/automation/token/impls.rs new file mode 100644 index 0000000000..923aafa618 --- /dev/null +++ b/src/libs/satellite/src/automation/token/impls.rs @@ -0,0 +1,34 @@ +use crate::automation::token::types::state::{AutomationTokenData, AutomationTokenKey}; +use crate::{Doc, SetDoc}; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_utils::encode_doc_data; + +impl AutomationTokenKey { + pub fn create(provider: &OpenIdAutomationProvider, jti: &str) -> Self { + Self { + provider: provider.clone(), + jti: jti.to_owned(), + } + } + + pub fn to_key(&self) -> String { + format!("{}#{}", self.provider, self.jti) + } +} + +impl AutomationTokenData { + pub fn prepare_set_doc( + token_data: &AutomationTokenData, + current_doc: &Option, + ) -> Result { + let data = encode_doc_data(token_data)?; + + let set_doc = SetDoc { + data, + description: None, + version: current_doc.as_ref().and_then(|d| d.version), + }; + + Ok(set_doc) + } +} diff --git a/src/libs/satellite/src/automation/token/mod.rs b/src/libs/satellite/src/automation/token/mod.rs new file mode 100644 index 0000000000..528a444196 --- /dev/null +++ b/src/libs/satellite/src/automation/token/mod.rs @@ -0,0 +1,6 @@ +pub mod assert; +mod impls; +mod services; +mod types; + +pub use services::*; diff --git a/src/libs/satellite/src/automation/token/services.rs b/src/libs/satellite/src/automation/token/services.rs new file mode 100644 index 0000000000..39fdc78c02 --- /dev/null +++ b/src/libs/satellite/src/automation/token/services.rs @@ -0,0 +1,71 @@ +use crate::automation::token::types::state::{AutomationTokenData, AutomationTokenKey}; +use crate::db::internal::unsafe_get_doc; +use crate::db::store::internal_set_doc_store; +use crate::db::types::store::AssertSetDocOptions; +use crate::errors::automation::{ + JUNO_AUTOMATION_TOKEN_ERROR_MISSING_JTI, JUNO_AUTOMATION_TOKEN_ERROR_TOKEN_REUSED, +}; +use crate::rules::store::get_rule_db; +use junobuild_auth::automation::types::PreparedAutomation; +use junobuild_auth::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_collections::constants::db::COLLECTION_AUTOMATION_TOKEN_KEY; +use junobuild_collections::msg::msg_db_collection_not_found; +use junobuild_shared::ic::api::id; +use junobuild_utils::DocDataPrincipal; + +pub fn save_unique_token_jti( + prepared_automation: &PreparedAutomation, + provider: &OpenIdAutomationProvider, + credential: &OpenIdAutomationCredential, +) -> Result<(), String> { + let jti = if let Some(jti) = &credential.jti { + jti + } else { + return Err(JUNO_AUTOMATION_TOKEN_ERROR_MISSING_JTI.to_string()); + }; + + let automation_token_key = AutomationTokenKey::create(provider, jti).to_key(); + + let automation_token_collection = COLLECTION_AUTOMATION_TOKEN_KEY.to_string(); + + let rule = get_rule_db(&automation_token_collection) + .ok_or_else(|| msg_db_collection_not_found(&automation_token_collection))?; + + let current_jti = unsafe_get_doc( + &automation_token_collection.to_string(), + &automation_token_key, + &rule, + )?; + + // ⚠️ Assertion to prevent replay attack. + if current_jti.is_some() { + return Err(JUNO_AUTOMATION_TOKEN_ERROR_TOKEN_REUSED.to_string()); + } + + // Create metadata. + let PreparedAutomation(controller_id, _) = prepared_automation; + + let automation_token_data: AutomationTokenData = AutomationTokenData { + controller_id: DocDataPrincipal { + value: controller_id.clone(), + }, + }; + + let automation_token_data = + AutomationTokenData::prepare_set_doc(&automation_token_data, &None)?; + + let assert_options = AssertSetDocOptions { + with_assert_rate: true, + }; + + internal_set_doc_store( + id(), + automation_token_collection, + automation_token_key, + automation_token_data, + &assert_options, + )?; + + Ok(()) +} diff --git a/src/libs/satellite/src/automation/token/types.rs b/src/libs/satellite/src/automation/token/types.rs new file mode 100644 index 0000000000..ae2ad1be86 --- /dev/null +++ b/src/libs/satellite/src/automation/token/types.rs @@ -0,0 +1,21 @@ +pub mod state { + use candid::Deserialize; + use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; + use junobuild_utils::DocDataPrincipal; + use serde::Serialize; + + /// A unique key for identifying an automation token. + /// Used to prevent replay attack + /// The key will be parsed to `provider#jti`. + #[derive(Serialize, Deserialize)] + pub struct AutomationTokenKey { + pub provider: OpenIdAutomationProvider, + pub jti: String, + } + + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + pub struct AutomationTokenData { + pub controller_id: DocDataPrincipal, + } +} diff --git a/src/libs/satellite/src/automation/types.rs b/src/libs/satellite/src/automation/types.rs new file mode 100644 index 0000000000..33e5b921f7 --- /dev/null +++ b/src/libs/satellite/src/automation/types.rs @@ -0,0 +1,20 @@ +use candid::{CandidType, Deserialize}; +use junobuild_auth::automation::types::{ + OpenIdPrepareAutomationArgs, PrepareAutomationError, PreparedAutomation, +}; +use serde::Serialize; + +#[derive(CandidType, Serialize, Deserialize)] +pub enum AuthenticateAutomationArgs { + OpenId(OpenIdPrepareAutomationArgs), +} + +#[derive(CandidType, Serialize, Deserialize)] +pub enum AuthenticationAutomationError { + PrepareAutomation(PrepareAutomationError), + SaveUniqueJtiToken(String), + SaveWorkflowMetadata(String), + RegisterController(String), +} + +pub type AuthenticateAutomationResult = Result; diff --git a/src/libs/satellite/src/automation/workflow/assert.rs b/src/libs/satellite/src/automation/workflow/assert.rs new file mode 100644 index 0000000000..ef030f59f6 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/assert.rs @@ -0,0 +1,21 @@ +use crate::errors::automation::JUNO_DATASTORE_ERROR_AUTOMATION_CALLER; +use candid::Principal; +use junobuild_collections::constants::db::COLLECTION_AUTOMATION_WORKFLOW_KEY; +use junobuild_collections::types::core::CollectionKey; +use junobuild_shared::ic::api::id; +use junobuild_shared::utils::principal_not_equal; + +pub fn assert_automation_workflow_caller( + caller: Principal, + collection: &CollectionKey, +) -> Result<(), String> { + if collection != COLLECTION_AUTOMATION_WORKFLOW_KEY { + return Ok(()); + } + + if principal_not_equal(id(), caller) { + return Err(JUNO_DATASTORE_ERROR_AUTOMATION_CALLER.to_string()); + } + + Ok(()) +} diff --git a/src/libs/satellite/src/automation/workflow/impls.rs b/src/libs/satellite/src/automation/workflow/impls.rs new file mode 100644 index 0000000000..714a4872a7 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/impls.rs @@ -0,0 +1,35 @@ +use crate::automation::workflow::types::state::{AutomationWorkflowData, AutomationWorkflowKey}; +use crate::{Doc, SetDoc}; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_utils::encode_doc_data; + +impl AutomationWorkflowKey { + pub fn create(provider: &OpenIdAutomationProvider, repository: &str, run_id: &str) -> Self { + Self { + provider: provider.clone(), + repository: repository.to_owned(), + run_id: run_id.to_owned(), + } + } + + pub fn to_key(&self) -> String { + format!("{}#{}#{}", self.provider, self.repository, self.run_id) + } +} + +impl AutomationWorkflowData { + pub fn prepare_set_doc( + workflow_data: &AutomationWorkflowData, + current_doc: &Option, + ) -> Result { + let data = encode_doc_data(workflow_data)?; + + let set_doc = SetDoc { + data, + description: None, + version: current_doc.as_ref().and_then(|d| d.version), + }; + + Ok(set_doc) + } +} diff --git a/src/libs/satellite/src/automation/workflow/mod.rs b/src/libs/satellite/src/automation/workflow/mod.rs new file mode 100644 index 0000000000..551e81b6bc --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/mod.rs @@ -0,0 +1,8 @@ +pub mod assert; +mod impls; +mod services; +mod types; +mod utils; + +pub use services::*; +pub use utils::*; diff --git a/src/libs/satellite/src/automation/workflow/services.rs b/src/libs/satellite/src/automation/workflow/services.rs new file mode 100644 index 0000000000..e42802aec9 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/services.rs @@ -0,0 +1,58 @@ +use crate::automation::workflow::types::state::AutomationWorkflowData; +use crate::automation::workflow::utils::build_automation_workflow_key; +use crate::db::internal::unsafe_get_doc; +use crate::db::store::internal_set_doc_store; +use crate::db::types::store::AssertSetDocOptions; +use crate::rules::store::get_rule_db; +use junobuild_auth::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; +use junobuild_collections::constants::db::COLLECTION_AUTOMATION_WORKFLOW_KEY; +use junobuild_collections::msg::msg_db_collection_not_found; +use junobuild_shared::ic::api::id; + +pub fn save_workflow_metadata( + provider: &OpenIdAutomationProvider, + credential: &OpenIdAutomationCredential, +) -> Result<(), String> { + let automation_workflow_key = build_automation_workflow_key(provider, credential)?.to_key(); + + let automation_workflow_collection = COLLECTION_AUTOMATION_WORKFLOW_KEY.to_string(); + + let rule = get_rule_db(&automation_workflow_collection) + .ok_or_else(|| msg_db_collection_not_found(&automation_workflow_collection))?; + + let current_automation_workflow = unsafe_get_doc( + &automation_workflow_collection.to_string(), + &automation_workflow_key, + &rule, + )?; + + // Create or update metadata. Since we are "only" saving the latest information, we always + // update the fields. + let automation_workflow_data: AutomationWorkflowData = AutomationWorkflowData { + run_number: credential.run_number.clone(), + run_attempt: credential.run_attempt.clone(), + r#ref: credential.r#ref.clone(), + }; + + let automation_workflow_data = AutomationWorkflowData::prepare_set_doc( + &automation_workflow_data, + ¤t_automation_workflow, + )?; + + let assert_options = AssertSetDocOptions { + // We disable the assertion for the rate because it has been asserted + // before when saving the jti. + with_assert_rate: false, + }; + + internal_set_doc_store( + id(), + automation_workflow_collection, + automation_workflow_key, + automation_workflow_data, + &assert_options, + )?; + + Ok(()) +} diff --git a/src/libs/satellite/src/automation/workflow/types.rs b/src/libs/satellite/src/automation/workflow/types.rs new file mode 100644 index 0000000000..4f45c15da1 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/types.rs @@ -0,0 +1,24 @@ +pub mod state { + use candid::Deserialize; + use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; + use serde::Serialize; + + /// A unique key for identifying an automation workflow. + /// The key will be parsed to `provider#repository#id`. + #[derive(Serialize, Deserialize)] + pub struct AutomationWorkflowKey { + pub provider: OpenIdAutomationProvider, + pub repository: String, + pub run_id: String, // e.g. run_id for GitHub, pipeline_id for GitLab + } + + /// Deployment workflow metadata. + /// Stores the latest state if a workflow has multiple attempts. + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + pub struct AutomationWorkflowData { + pub run_number: Option, // The number of times this workflow has been run. + pub run_attempt: Option, // The number of times this workflow run has been retried. + pub r#ref: Option, // (Reference) The latest git ref that triggered the workflow run. e.g. "refs/heads/main" + } +} diff --git a/src/libs/satellite/src/automation/workflow/utils.rs b/src/libs/satellite/src/automation/workflow/utils.rs new file mode 100644 index 0000000000..ed50122497 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/utils.rs @@ -0,0 +1,28 @@ +use crate::automation::workflow::types::state::AutomationWorkflowKey; +use crate::errors::automation::{ + JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_REPOSITORY, + JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_RUN_ID, +}; +use junobuild_auth::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use junobuild_auth::openid::types::provider::OpenIdAutomationProvider; + +pub fn build_automation_workflow_key( + provider: &OpenIdAutomationProvider, + credential: &OpenIdAutomationCredential, +) -> Result { + let repository = if let Some(repository) = &credential.repository { + repository + } else { + return Err(JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_REPOSITORY.to_string()); + }; + + let run_id = if let Some(run_id) = &credential.run_id { + run_id + } else { + return Err(JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_RUN_ID.to_string()); + }; + + let automation_workflow_key = AutomationWorkflowKey::create(provider, repository, run_id); + + Ok(automation_workflow_key) +} diff --git a/src/libs/satellite/src/db/assert.rs b/src/libs/satellite/src/db/assert.rs index c041f2b4b8..7d92c59b79 100644 --- a/src/libs/satellite/src/db/assert.rs +++ b/src/libs/satellite/src/db/assert.rs @@ -1,4 +1,5 @@ use crate::auth::assert::assert_caller_is_allowed; +use crate::automation::{assert_automation_token_caller, assert_automation_workflow_caller}; use crate::db::runtime::increment_and_assert_rate; use crate::db::types::config::DbConfig; use crate::db::types::interface::SetDbConfig; @@ -84,6 +85,10 @@ pub fn assert_set_doc( assert_user_webauthn_collection_data(caller, collection, value)?; assert_user_webauthn_collection_write_permission(collection, current_doc)?; + // Note: we do not assert the format of the automation keys or data since only the Satellite can write. + assert_automation_token_caller(caller, collection)?; + assert_automation_workflow_caller(caller, collection)?; + assert_write_permission(caller, controllers, current_doc, &rule.write)?; assert_memory_size(config)?; @@ -133,6 +138,9 @@ pub fn assert_delete_doc( assert_write_version(current_doc, value.version)?; + assert_automation_token_caller(caller, collection)?; + assert_automation_workflow_caller(caller, collection)?; + invoke_assert_delete_doc( &caller, &DocContext { diff --git a/src/libs/satellite/src/errors/automation.rs b/src/libs/satellite/src/errors/automation.rs new file mode 100644 index 0000000000..cb3d42db09 --- /dev/null +++ b/src/libs/satellite/src/errors/automation.rs @@ -0,0 +1,10 @@ +pub const JUNO_AUTOMATION_TOKEN_ERROR_MISSING_JTI: &str = "juno.automation.token.error.missing_jti"; +pub const JUNO_AUTOMATION_TOKEN_ERROR_TOKEN_REUSED: &str = + "juno.automation.token.error.token_reused"; + +pub const JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_REPOSITORY: &str = + "juno.automation.workflow.error.missing_repository"; +pub const JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_RUN_ID: &str = + "juno.automation.workflow.error.missing_run_id"; + +pub const JUNO_DATASTORE_ERROR_AUTOMATION_CALLER: &str = "juno.datastore.error.automation.caller"; diff --git a/src/libs/satellite/src/errors/mod.rs b/src/libs/satellite/src/errors/mod.rs index 005e7c4c52..9c2c666fce 100644 --- a/src/libs/satellite/src/errors/mod.rs +++ b/src/libs/satellite/src/errors/mod.rs @@ -1,3 +1,4 @@ pub mod auth; +pub mod automation; pub mod db; pub mod user; diff --git a/src/libs/satellite/src/impls.rs b/src/libs/satellite/src/impls.rs index 0b31f105fd..3e2abd2eff 100644 --- a/src/libs/satellite/src/impls.rs +++ b/src/libs/satellite/src/impls.rs @@ -1,6 +1,8 @@ +use crate::automation::types::AuthenticateAutomationResult; use crate::memory::internal::init_stable_state; use crate::types::interface::{ - AuthenticateResultResponse, AuthenticationResult, GetDelegationResultResponse, + AuthenticateAutomationResultResponse, AuthenticateResultResponse, AuthenticationResult, + GetDelegationResultResponse, }; use crate::types::state::{CollectionType, HeapState, RuntimeState, State}; use junobuild_auth::delegation::types::{GetDelegationError, SignedDelegation}; @@ -46,3 +48,12 @@ impl From for AuthenticateResultResponse { } } } + +impl From for AuthenticateAutomationResultResponse { + fn from(r: AuthenticateAutomationResult) -> Self { + match r { + Ok(v) => Self::Ok(v), + Err(e) => Self::Err(e), + } + } +} diff --git a/src/libs/satellite/src/lib.rs b/src/libs/satellite/src/lib.rs index 2892342603..867020936a 100644 --- a/src/libs/satellite/src/lib.rs +++ b/src/libs/satellite/src/lib.rs @@ -25,10 +25,11 @@ use crate::guards::{ caller_is_admin_controller, caller_is_controller_with_write, caller_is_valid_controller, }; use crate::types::interface::{ - AuthenticateResultResponse, AuthenticationArgs, Config, DeleteProposalAssets, - GetDelegationArgs, GetDelegationResultResponse, + AuthenticateAutomationResultResponse, AuthenticateResultResponse, AuthenticationArgs, Config, + DeleteProposalAssets, GetDelegationArgs, GetDelegationResultResponse, }; use crate::types::state::CollectionType; +use automation::types::AuthenticateAutomationArgs; use ic_cdk_macros::{init, post_upgrade, pre_upgrade, query, update}; use junobuild_auth::state::types::automation::AutomationConfig; use junobuild_auth::state::types::config::AuthenticationConfig; @@ -178,6 +179,14 @@ pub fn get_delegation(args: GetDelegationArgs) -> GetDelegationResultResponse { api::auth::get_delegation(&args).into() } +#[doc(hidden)] +#[update] +pub async fn authenticate_automation( + args: AuthenticateAutomationArgs, +) -> AuthenticateAutomationResultResponse { + api::automation::authenticate_automation(args).await.into() +} + // --------------------------------------------------------- // Rules // --------------------------------------------------------- @@ -558,10 +567,10 @@ pub fn memory_size() -> MemorySize { macro_rules! include_satellite { () => { use junobuild_satellite::{ - authenticate, commit_asset_upload, commit_proposal, commit_proposal_asset_upload, - commit_proposal_many_assets_upload, count_assets, count_collection_assets, - count_collection_docs, count_docs, count_proposals, del_asset, del_assets, - del_controllers, del_custom_domain, del_doc, del_docs, del_filtered_assets, + authenticate, authenticate_automation, commit_asset_upload, commit_proposal, + commit_proposal_asset_upload, commit_proposal_many_assets_upload, count_assets, + count_collection_assets, count_collection_docs, count_docs, count_proposals, del_asset, + del_assets, del_controllers, del_custom_domain, del_doc, del_docs, del_filtered_assets, del_filtered_docs, del_many_assets, del_many_docs, del_rule, delete_proposal_assets, deposit_cycles, get_asset, get_auth_config, get_automation_config, get_config, get_db_config, get_delegation, get_doc, get_many_assets, get_many_docs, get_proposal, diff --git a/src/libs/satellite/src/memory/lifecycle.rs b/src/libs/satellite/src/memory/lifecycle.rs index 58d132baa3..a1471bc16c 100644 --- a/src/libs/satellite/src/memory/lifecycle.rs +++ b/src/libs/satellite/src/memory/lifecycle.rs @@ -6,6 +6,7 @@ use crate::memory::internal::{get_memory_for_upgrade, init_stable_state}; use crate::memory::state::STATE; use crate::memory::utils::init_storage_heap_state; use crate::random::init::defer_init_random_seed; +use crate::rules::upgrade::init_automation_collections; use crate::types::state::{HeapState, RuntimeState, State}; use ciborium::{from_reader, into_writer}; use junobuild_shared::memory::upgrade::{read_post_upgrade, write_pre_upgrade}; @@ -61,4 +62,7 @@ pub fn post_upgrade() { invoke_on_post_upgrade_sync(); invoke_on_post_upgrade(); + + // TODO: to be removed - one time upgrade! + init_automation_collections(); } diff --git a/src/libs/satellite/src/rules/mod.rs b/src/libs/satellite/src/rules/mod.rs index 19e827c9ab..4fdf93e1bd 100644 --- a/src/libs/satellite/src/rules/mod.rs +++ b/src/libs/satellite/src/rules/mod.rs @@ -1,3 +1,4 @@ mod internal; pub mod store; pub mod switch_memory; +pub mod upgrade; diff --git a/src/libs/satellite/src/rules/upgrade.rs b/src/libs/satellite/src/rules/upgrade.rs new file mode 100644 index 0000000000..5a00a07bd5 --- /dev/null +++ b/src/libs/satellite/src/rules/upgrade.rs @@ -0,0 +1,80 @@ +use crate::memory::state::STATE; +use ic_cdk::api::time; +use junobuild_collections::constants::db::{ + COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE, COLLECTION_AUTOMATION_TOKEN_KEY, + COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE, COLLECTION_AUTOMATION_WORKFLOW_KEY, +}; +use junobuild_collections::types::rules::Rule; + +// --------------------------------------------------------- +// One time upgrade +// --------------------------------------------------------- + +pub fn init_automation_collections() { + init_automation_token_collection(); + init_automation_workflow_collection(); +} + +fn init_automation_token_collection() { + let col = STATE.with(|state| { + let rules = &state.borrow_mut().heap.db.rules; + rules.get(COLLECTION_AUTOMATION_TOKEN_KEY).cloned() + }); + + if col.is_none() { + STATE.with(|state| { + let rules = &mut state.borrow_mut().heap.db.rules; + + let now = time(); + + let rule = Rule { + read: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.read, + write: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.write, + memory: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.memory, + mutable_permissions: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.mutable_permissions, + max_size: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.max_size, + max_capacity: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.max_capacity, + max_changes_per_user: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.max_changes_per_user, + created_at: now, + updated_at: now, + version: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.version, + rate_config: COLLECTION_AUTOMATION_TOKEN_DEFAULT_RULE.rate_config, + }; + + rules.insert(COLLECTION_AUTOMATION_TOKEN_KEY.to_string(), rule.clone()); + }); + } +} + +fn init_automation_workflow_collection() { + let col = STATE.with(|state| { + let rules = &state.borrow_mut().heap.db.rules; + rules.get(COLLECTION_AUTOMATION_WORKFLOW_KEY).cloned() + }); + + if col.is_none() { + STATE.with(|state| { + let rules = &mut state.borrow_mut().heap.db.rules; + + let now = time(); + + let rule = Rule { + read: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.read, + write: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.write, + memory: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.memory, + mutable_permissions: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE + .mutable_permissions, + max_size: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.max_size, + max_capacity: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.max_capacity, + max_changes_per_user: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE + .max_changes_per_user, + created_at: now, + updated_at: now, + version: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.version, + rate_config: COLLECTION_AUTOMATION_WORKFLOW_DEFAULT_RULE.rate_config, + }; + + rules.insert(COLLECTION_AUTOMATION_WORKFLOW_KEY.to_string(), rule.clone()); + }); + } +} diff --git a/src/libs/satellite/src/types.rs b/src/libs/satellite/src/types.rs index 97915462a6..5b180513d0 100644 --- a/src/libs/satellite/src/types.rs +++ b/src/libs/satellite/src/types.rs @@ -56,9 +56,11 @@ pub mod state { } pub mod interface { + use crate::automation::types::AuthenticationAutomationError; use crate::db::types::config::DbConfig; use crate::Doc; use candid::CandidType; + use junobuild_auth::automation::types::PreparedAutomation; use junobuild_auth::delegation::types::{ GetDelegationError, OpenIdGetDelegationArgs, OpenIdPrepareDelegationArgs, PrepareDelegationError, PreparedDelegation, SignedDelegation, @@ -120,6 +122,12 @@ pub mod interface { Ok(SignedDelegation), Err(GetDelegationError), } + + #[derive(CandidType, Serialize, Deserialize)] + pub enum AuthenticateAutomationResultResponse { + Ok(PreparedAutomation), + Err(AuthenticationAutomationError), + } } pub mod store { diff --git a/src/satellite/satellite.did b/src/satellite/satellite.did index a9f283e989..4ca3e33935 100644 --- a/src/satellite/satellite.did +++ b/src/satellite/satellite.did @@ -22,12 +22,25 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateAutomationArgs = variant { + OpenId : OpenIdPrepareAutomationArgs; +}; +type AuthenticateAutomationResultResponse = variant { + Ok : record { principal; AutomationController }; + Err : AuthenticationAutomationError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; }; type Authentication = record { doc : Doc; delegation : PreparedDelegation }; type AuthenticationArgs = variant { OpenId : OpenIdPrepareDelegationArgs }; +type AuthenticationAutomationError = variant { + PrepareAutomation : PrepareAutomationError; + RegisterController : text; + SaveWorkflowMetadata : text; + SaveUniqueJtiToken : text; +}; type AuthenticationConfig = record { updated_at : opt nat64; openid : opt AuthenticationConfigOpenId; @@ -62,6 +75,10 @@ type AutomationConfigOpenId = record { OpenIdAutomationProviderConfig; }; }; +type AutomationController = record { + scope : AutomationScope; + expires_at : nat64; +}; type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { @@ -125,6 +142,7 @@ type GetDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type GetDelegationResultResponse = variant { Ok : SignedDelegation; @@ -254,18 +272,30 @@ type OpenIdGetDelegationArgs = record { salt : blob; expiration : nat64; }; +type OpenIdPrepareAutomationArgs = record { jwt : text; salt : blob }; type OpenIdPrepareDelegationArgs = record { jwt : text; session_key : blob; salt : blob; }; type Permission = variant { Controllers; Private; Public; Managed }; +type PrepareAutomationError = variant { + JwtFindProvider : JwtFindProviderError; + InvalidController : text; + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; + ControllerAlreadyExists; + InvalidObservatoryId : text; + TooManyControllers : text; +}; type PrepareDelegationError = variant { JwtFindProvider : JwtFindProviderError; GetCachedJwks; JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type PreparedDelegation = record { user_key : blob; expiration : nat64 }; type Proposal = record { @@ -408,6 +438,9 @@ type UploadChunk = record { type UploadChunkResult = record { chunk_id : nat }; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_automation : (AuthenticateAutomationArgs) -> ( + AuthenticateAutomationResultResponse, + ); commit_asset_upload : (CommitBatch) -> (); commit_proposal : (CommitProposal) -> (null); commit_proposal_asset_upload : (CommitBatch) -> (); diff --git a/src/sputnik/sputnik.did b/src/sputnik/sputnik.did index 14133c3d4c..72a4b5048b 100644 --- a/src/sputnik/sputnik.did +++ b/src/sputnik/sputnik.did @@ -22,12 +22,25 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateAutomationArgs = variant { + OpenId : OpenIdPrepareAutomationArgs; +}; +type AuthenticateAutomationResultResponse = variant { + Ok : record { principal; AutomationController }; + Err : AuthenticationAutomationError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; }; type Authentication = record { doc : Doc; delegation : PreparedDelegation }; type AuthenticationArgs = variant { OpenId : OpenIdPrepareDelegationArgs }; +type AuthenticationAutomationError = variant { + PrepareAutomation : PrepareAutomationError; + RegisterController : text; + SaveWorkflowMetadata : text; + SaveUniqueJtiToken : text; +}; type AuthenticationConfig = record { updated_at : opt nat64; openid : opt AuthenticationConfigOpenId; @@ -62,6 +75,10 @@ type AutomationConfigOpenId = record { OpenIdAutomationProviderConfig; }; }; +type AutomationController = record { + scope : AutomationScope; + expires_at : nat64; +}; type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { @@ -125,6 +142,7 @@ type GetDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type GetDelegationResultResponse = variant { Ok : SignedDelegation; @@ -254,18 +272,30 @@ type OpenIdGetDelegationArgs = record { salt : blob; expiration : nat64; }; +type OpenIdPrepareAutomationArgs = record { jwt : text; salt : blob }; type OpenIdPrepareDelegationArgs = record { jwt : text; session_key : blob; salt : blob; }; type Permission = variant { Controllers; Private; Public; Managed }; +type PrepareAutomationError = variant { + JwtFindProvider : JwtFindProviderError; + InvalidController : text; + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; + ControllerAlreadyExists; + InvalidObservatoryId : text; + TooManyControllers : text; +}; type PrepareDelegationError = variant { JwtFindProvider : JwtFindProviderError; GetCachedJwks; JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type PreparedDelegation = record { user_key : blob; expiration : nat64 }; type Proposal = record { @@ -408,6 +438,9 @@ type UploadChunk = record { type UploadChunkResult = record { chunk_id : nat }; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_automation : (AuthenticateAutomationArgs) -> ( + AuthenticateAutomationResultResponse, + ); commit_asset_upload : (CommitBatch) -> (); commit_proposal : (CommitProposal) -> (null); commit_proposal_asset_upload : (CommitBatch) -> (); diff --git a/src/tests/declarations/test_satellite/test_satellite.did.d.ts b/src/tests/declarations/test_satellite/test_satellite.did.d.ts index bc4fc1b3e8..83ab17b98a 100644 --- a/src/tests/declarations/test_satellite/test_satellite.did.d.ts +++ b/src/tests/declarations/test_satellite/test_satellite.did.d.ts @@ -34,12 +34,27 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateAutomationArgs = { + OpenId: OpenIdPrepareAutomationArgs; +}; +export type AuthenticateAutomationResultResponse = + | { + Ok: [Principal, AutomationController]; + } + | { Err: AuthenticationAutomationError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; delegation: PreparedDelegation; } export type AuthenticationArgs = { OpenId: OpenIdPrepareDelegationArgs }; +export type AuthenticationAutomationError = + | { + PrepareAutomation: PrepareAutomationError; + } + | { RegisterController: string } + | { SaveWorkflowMetadata: string } + | { SaveUniqueJtiToken: string }; export interface AuthenticationConfig { updated_at: [] | [bigint]; openid: [] | [AuthenticationConfigOpenId]; @@ -74,6 +89,10 @@ export interface AutomationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdAutomationProvider, OpenIdAutomationProviderConfig]>; } +export interface AutomationController { + scope: AutomationScope; + expires_at: bigint; +} export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { @@ -153,7 +172,8 @@ export type GetDelegationError = | { NoSuchDelegation: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export type GetDelegationResultResponse = { Ok: SignedDelegation } | { Err: GetDelegationError }; export type GetOrRefreshJwksError = | { InvalidConfig: string } @@ -300,6 +320,10 @@ export interface OpenIdGetDelegationArgs { salt: Uint8Array; expiration: bigint; } +export interface OpenIdPrepareAutomationArgs { + jwt: string; + salt: Uint8Array; +} export interface OpenIdPrepareDelegationArgs { jwt: string; session_key: Uint8Array; @@ -310,6 +334,17 @@ export type Permission = | { Private: null } | { Public: null } | { Managed: null }; +export type PrepareAutomationError = + | { + JwtFindProvider: JwtFindProviderError; + } + | { InvalidController: string } + | { GetCachedJwks: null } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError } + | { ControllerAlreadyExists: null } + | { InvalidObservatoryId: string } + | { TooManyControllers: string }; export type PrepareDelegationError = | { JwtFindProvider: JwtFindProviderError; @@ -317,7 +352,8 @@ export type PrepareDelegationError = | { GetCachedJwks: null } | { JwtVerify: JwtVerifyError } | { GetOrFetchJwks: GetOrRefreshJwksError } - | { DeriveSeedFailed: string }; + | { DeriveSeedFailed: string } + | { InvalidObservatoryId: string }; export interface PreparedDelegation { user_key: Uint8Array; expiration: bigint; @@ -476,6 +512,10 @@ export interface UploadChunkResult { } export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_automation: ActorMethod< + [AuthenticateAutomationArgs], + AuthenticateAutomationResultResponse + >; commit_asset_upload: ActorMethod<[CommitBatch], undefined>; commit_proposal: ActorMethod<[CommitProposal], null>; commit_proposal_asset_upload: ActorMethod<[CommitBatch], undefined>; diff --git a/src/tests/declarations/test_satellite/test_satellite.factory.certified.did.js b/src/tests/declarations/test_satellite/test_satellite.factory.certified.did.js index d7f51fcc4d..347f628b11 100644 --- a/src/tests/declarations/test_satellite/test_satellite.factory.certified.did.js +++ b/src/tests/declarations/test_satellite/test_satellite.factory.certified.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -485,6 +518,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/tests/declarations/test_satellite/test_satellite.factory.did.js b/src/tests/declarations/test_satellite/test_satellite.factory.did.js index a79a5c2fad..7703e9acaa 100644 --- a/src/tests/declarations/test_satellite/test_satellite.factory.did.js +++ b/src/tests/declarations/test_satellite/test_satellite.factory.did.js @@ -65,7 +65,8 @@ export const idlFactory = ({ IDL }) => { GetCachedJwks: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const AuthenticationError = IDL.Variant({ PrepareDelegation: PrepareDelegationError, @@ -75,6 +76,41 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const OpenIdPrepareAutomationArgs = IDL.Record({ + jwt: IDL.Text, + salt: IDL.Vec(IDL.Nat8) + }); + const AuthenticateAutomationArgs = IDL.Variant({ + OpenId: OpenIdPrepareAutomationArgs + }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const AutomationController = IDL.Record({ + scope: AutomationScope, + expires_at: IDL.Nat64 + }); + const PrepareAutomationError = IDL.Variant({ + JwtFindProvider: JwtFindProviderError, + InvalidController: IDL.Text, + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError, + ControllerAlreadyExists: IDL.Null, + InvalidObservatoryId: IDL.Text, + TooManyControllers: IDL.Text + }); + const AuthenticationAutomationError = IDL.Variant({ + PrepareAutomation: PrepareAutomationError, + RegisterController: IDL.Text, + SaveWorkflowMetadata: IDL.Text, + SaveUniqueJtiToken: IDL.Text + }); + const AuthenticateAutomationResultResponse = IDL.Variant({ + Ok: IDL.Tuple(IDL.Principal, AutomationController), + Err: AuthenticationAutomationError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -195,10 +231,6 @@ export const idlFactory = ({ IDL }) => { rules: IDL.Opt(AuthenticationRules) }); const OpenIdAutomationProvider = IDL.Variant({ GitHub: IDL.Null }); - const AutomationScope = IDL.Variant({ - Write: IDL.Null, - Submit: IDL.Null - }); const OpenIdAutomationProviderControllerConfig = IDL.Record({ scope: IDL.Opt(AutomationScope), max_time_to_live: IDL.Opt(IDL.Nat64) @@ -283,7 +315,8 @@ export const idlFactory = ({ IDL }) => { NoSuchDelegation: IDL.Null, JwtVerify: JwtVerifyError, GetOrFetchJwks: GetOrRefreshJwksError, - DeriveSeedFailed: IDL.Text + DeriveSeedFailed: IDL.Text, + InvalidObservatoryId: IDL.Text }); const GetDelegationResultResponse = IDL.Variant({ Ok: SignedDelegation, @@ -485,6 +518,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_automation: IDL.Func( + [AuthenticateAutomationArgs], + [AuthenticateAutomationResultResponse], + [] + ), commit_asset_upload: IDL.Func([CommitBatch], [], []), commit_proposal: IDL.Func([CommitProposal], [IDL.Null], []), commit_proposal_asset_upload: IDL.Func([CommitBatch], [], []), diff --git a/src/tests/fixtures/test_satellite/test_satellite.did b/src/tests/fixtures/test_satellite/test_satellite.did index ff9915dece..4053ee5e34 100644 --- a/src/tests/fixtures/test_satellite/test_satellite.did +++ b/src/tests/fixtures/test_satellite/test_satellite.did @@ -22,12 +22,25 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateAutomationArgs = variant { + OpenId : OpenIdPrepareAutomationArgs; +}; +type AuthenticateAutomationResultResponse = variant { + Ok : record { principal; AutomationController }; + Err : AuthenticationAutomationError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; }; type Authentication = record { doc : Doc; delegation : PreparedDelegation }; type AuthenticationArgs = variant { OpenId : OpenIdPrepareDelegationArgs }; +type AuthenticationAutomationError = variant { + PrepareAutomation : PrepareAutomationError; + RegisterController : text; + SaveWorkflowMetadata : text; + SaveUniqueJtiToken : text; +}; type AuthenticationConfig = record { updated_at : opt nat64; openid : opt AuthenticationConfigOpenId; @@ -62,6 +75,10 @@ type AutomationConfigOpenId = record { OpenIdAutomationProviderConfig; }; }; +type AutomationController = record { + scope : AutomationScope; + expires_at : nat64; +}; type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { @@ -125,6 +142,7 @@ type GetDelegationError = variant { JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type GetDelegationResultResponse = variant { Ok : SignedDelegation; @@ -254,18 +272,30 @@ type OpenIdGetDelegationArgs = record { salt : blob; expiration : nat64; }; +type OpenIdPrepareAutomationArgs = record { jwt : text; salt : blob }; type OpenIdPrepareDelegationArgs = record { jwt : text; session_key : blob; salt : blob; }; type Permission = variant { Controllers; Private; Public; Managed }; +type PrepareAutomationError = variant { + JwtFindProvider : JwtFindProviderError; + InvalidController : text; + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; + ControllerAlreadyExists; + InvalidObservatoryId : text; + TooManyControllers : text; +}; type PrepareDelegationError = variant { JwtFindProvider : JwtFindProviderError; GetCachedJwks; JwtVerify : JwtVerifyError; GetOrFetchJwks : GetOrRefreshJwksError; DeriveSeedFailed : text; + InvalidObservatoryId : text; }; type PreparedDelegation = record { user_key : blob; expiration : nat64 }; type Proposal = record { @@ -408,6 +438,9 @@ type UploadChunk = record { type UploadChunkResult = record { chunk_id : nat }; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_automation : (AuthenticateAutomationArgs) -> ( + AuthenticateAutomationResultResponse, + ); commit_asset_upload : (CommitBatch) -> (); commit_proposal : (CommitProposal) -> (null); commit_proposal_asset_upload : (CommitBatch) -> (); diff --git a/src/tests/mocks/automation.mocks.ts b/src/tests/mocks/automation.mocks.ts index c52855973e..296ee86856 100644 --- a/src/tests/mocks/automation.mocks.ts +++ b/src/tests/mocks/automation.mocks.ts @@ -4,3 +4,16 @@ export const mockRepositoryKey: SatelliteDid.RepositoryKey = { owner: 'junobuild', name: 'juno' }; + +export interface AutomationWorkflowData { + runNumber?: string; + runAttempt?: string; + ref?: string; +} + +export const mockAutomationWorkflowData: Required & { runId: string } = { + runId: '21776509605', + runNumber: '1', + runAttempt: '2', + ref: 'refs/heads/main' +}; diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.authenticate.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.authenticate.spec.ts new file mode 100644 index 0000000000..2925c28376 --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.authenticate.spec.ts @@ -0,0 +1,39 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { mockCertificateDate } from '../../../../mocks/jwt.mocks'; +import { testAutomationAuthenticate } from '../../../../utils/automation-assertions-authenticate-tests.utils'; +import { setupSatelliteStock } from '../../../../utils/satellite-tests.utils'; + +describe('Satellite > Authentication > Authenticate', () => { + let pic: PocketIc; + + let actor: Actor; + let controller: Ed25519KeyIdentity; + + beforeAll(async () => { + const { + actor: a, + pic: p, + controller: cO + } = await setupSatelliteStock({ + dateTime: mockCertificateDate, + withIndexHtml: false, + memory: { Heap: null } + }); + + pic = p; + actor = a; + controller = cO; + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + testAutomationAuthenticate({ + pic: () => pic, + actor: () => actor, + controller: () => controller + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.config.openid.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.config.openid.spec.ts new file mode 100644 index 0000000000..2ea7ad73de --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.config.openid.spec.ts @@ -0,0 +1,39 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { mockCertificateDate } from '../../../../mocks/jwt.mocks'; +import { testAutomationConfigObservatory } from '../../../../utils/automation-assertions-config-openid-tests.utils'; +import { setupSatelliteStock } from '../../../../utils/satellite-tests.utils'; + +describe('Satellite > Automation > Config', () => { + let pic: PocketIc; + + let actor: Actor; + let controller: Ed25519KeyIdentity; + + beforeAll(async () => { + const { + actor: a, + pic: p, + controller: cO + } = await setupSatelliteStock({ + dateTime: mockCertificateDate, + withIndexHtml: false, + memory: { Heap: null } + }); + + pic = p; + actor = a; + controller = cO; + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + testAutomationConfigObservatory({ + pic: () => pic, + actor: () => actor, + controller: () => controller + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.controller.config.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.config.spec.ts new file mode 100644 index 0000000000..456c7fc382 --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.config.spec.ts @@ -0,0 +1,87 @@ +import type { SatelliteActor, SatelliteDid } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { fromNullable } from '@dfinity/utils'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { authenticateAutomationAndMakeController } from '../../../../utils/automation-controller-tests.utils'; +import { + setupSatelliteAutomation, + type TestAutomation +} from '../../../../utils/automation-tests.utils'; + +describe('Satellite > Automation > Controller > Config', () => { + let pic: PocketIc; + let satelliteActor: Actor; + let controller: Ed25519KeyIdentity; + + let automation: TestAutomation; + + const oneMin = 60n * 1_000_000_000n; + const customMaxTimeToLive = oneMin * 25n; // 25min in nanoseconds + + beforeAll(async () => { + const controllerConfig: SatelliteDid.OpenIdAutomationProviderControllerConfig = { + scope: [{ Submit: null }], + max_time_to_live: [customMaxTimeToLive] + }; + + const { + pic: p, + satellite: { actor }, + automation: s, + controller: c + } = await setupSatelliteAutomation({ + controllerConfig + }); + + pic = p; + satelliteActor = actor; + controller = c; + + automation = s; + + await authenticateAutomationAndMakeController({ + pic, + actor: satelliteActor, + automation + }); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + it('should authenticate with a custom scope', async () => { + satelliteActor.setIdentity(controller); + + const { list_controllers: admin_list_controllers } = satelliteActor; + + const controllers = await admin_list_controllers(); + + const automatedController = controllers.find( + ([p, _]) => p.toText() === automation.automationIdentity.getPrincipal().toText() + ); + + expect(automatedController).not.toBeUndefined(); + expect('Submit' in (automatedController?.[1].scope ?? {})).toBeTruthy(); + }); + + it('should authenticate with a custom expiration', async () => { + satelliteActor.setIdentity(controller); + + const { list_controllers: admin_list_controllers } = satelliteActor; + + const controllers = await admin_list_controllers(); + + const automatedController = controllers.find( + ([p, _]) => p.toText() === automation.automationIdentity.getPrincipal().toText() + ); + + const expiresAt = fromNullable(automatedController?.[1].expires_at ?? []); + + const now = await pic.getTime(); + + expect(expiresAt).not.toBeUndefined(); + expect(expiresAt).toBeGreaterThanOrEqual(BigInt(now * 1_000_000) - oneMin); // 1min as test margin + expect(expiresAt).toBeLessThan(BigInt(now * 1_000_000) + customMaxTimeToLive + oneMin); // 1min as test margin + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.controller.expiration.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.expiration.spec.ts new file mode 100644 index 0000000000..3b3e2c9d32 --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.expiration.spec.ts @@ -0,0 +1,76 @@ +import type { SatelliteActor, SatelliteDid } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { fromNullable } from '@dfinity/utils'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { authenticateAutomationAndMakeController } from '../../../../utils/automation-controller-tests.utils'; +import { + setupSatelliteAutomation, + type TestAutomation +} from '../../../../utils/automation-tests.utils'; + +describe('Satellite > Automation > Expiration', () => { + let pic: PocketIc; + let satelliteActor: Actor; + let controller: Ed25519KeyIdentity; + + let automation: TestAutomation; + + const oneMin = 60n * 1_000_000_000n; + const customMaxTimeToLive = oneMin * 120n; // 2h in nanoseconds + + beforeAll(async () => { + const controllerConfig: SatelliteDid.OpenIdAutomationProviderControllerConfig = { + scope: [{ Submit: null }], + max_time_to_live: [customMaxTimeToLive] + }; + + const { + pic: p, + satellite: { actor }, + automation: s, + controller: c + } = await setupSatelliteAutomation({ + controllerConfig + }); + + pic = p; + satelliteActor = actor; + controller = c; + + automation = s; + + await authenticateAutomationAndMakeController({ + pic, + actor: satelliteActor, + automation + }); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + it('should cap expiration to 1 hour maximum', async () => { + satelliteActor.setIdentity(controller); + + const { list_controllers: admin_list_controllers } = satelliteActor; + + const controllers = await admin_list_controllers(); + + const automatedController = controllers.find( + ([p, _]) => p.toText() === automation.automationIdentity.getPrincipal().toText() + ); + + const expiresAt = fromNullable(automatedController?.[1].expires_at ?? []); + + const now = await pic.getTime(); + + expect(expiresAt).not.toBeUndefined(); + + const oneHour = oneMin * 60n; + + expect(expiresAt).toBeGreaterThan(BigInt(now * 1_000_000)); + expect(expiresAt).toBeLessThan(BigInt(now * 1_000_000) + oneHour + oneMin); // 1min margin + expect(expiresAt).toBeGreaterThan(BigInt(now * 1_000_000) + oneHour - oneMin); // Within 1min of 1 hour + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.controller.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.spec.ts new file mode 100644 index 0000000000..11be64926e --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.controller.spec.ts @@ -0,0 +1,52 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + assertAutomationController, + authenticateAutomationAndMakeController +} from '../../../../utils/automation-controller-tests.utils'; +import { + setupSatelliteAutomation, + type TestAutomation +} from '../../../../utils/automation-tests.utils'; + +describe('Satellite > Automation > Controller', () => { + let pic: PocketIc; + let satelliteActor: Actor; + let controller: Ed25519KeyIdentity; + + let automation: TestAutomation; + + beforeAll(async () => { + const { + pic: p, + satellite: { actor }, + automation: s, + controller: c + } = await setupSatelliteAutomation(); + + pic = p; + satelliteActor = actor; + controller = c; + + automation = s; + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + it('should authenticate automation and use controller to perform a call', async () => { + await authenticateAutomationAndMakeController({ + pic, + actor: satelliteActor, + automation + }); + + await assertAutomationController({ + controller, + satelliteActor, + automationIdentity: automation.automationIdentity + }); + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.token.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.token.spec.ts new file mode 100644 index 0000000000..e143deaa79 --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.token.spec.ts @@ -0,0 +1,39 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { mockCertificateDate } from '../../../../mocks/jwt.mocks'; +import { testAutomationToken } from '../../../../utils/automation-assertions-token-tests.utils'; +import { setupSatelliteStock } from '../../../../utils/satellite-tests.utils'; + +describe('Satellite > Authentication > Token', () => { + let pic: PocketIc; + + let actor: Actor; + let controller: Ed25519KeyIdentity; + + beforeAll(async () => { + const { + actor: a, + pic: p, + controller: cO + } = await setupSatelliteStock({ + dateTime: mockCertificateDate, + withIndexHtml: false, + memory: { Heap: null } + }); + + pic = p; + actor = a; + controller = cO; + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + testAutomationToken({ + pic: () => pic, + actor: () => actor, + controller: () => controller + }); +}); diff --git a/src/tests/specs/satellite/stock/automation/satellite.automation.workflow.spec.ts b/src/tests/specs/satellite/stock/automation/satellite.automation.workflow.spec.ts new file mode 100644 index 0000000000..3c0b064ec2 --- /dev/null +++ b/src/tests/specs/satellite/stock/automation/satellite.automation.workflow.spec.ts @@ -0,0 +1,61 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { fromArray } from '@junobuild/utils'; +import { mockAutomationWorkflowData, mockRepositoryKey } from '../../../../mocks/automation.mocks'; +import { mockListParams } from '../../../../mocks/list.mocks'; +import { authenticateAutomationAndMakeController } from '../../../../utils/automation-controller-tests.utils'; +import { + setupSatelliteAutomation, + type TestAutomation +} from '../../../../utils/automation-tests.utils'; + +describe('Satellite > Automation > Workflow', () => { + let pic: PocketIc; + let satelliteActor: Actor; + + let automation: TestAutomation; + + beforeAll(async () => { + const { + pic: p, + satellite: { actor }, + automation: s + } = await setupSatelliteAutomation(); + + pic = p; + satelliteActor = actor; + + automation = s; + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + it('should authenticate automation and log the deployment', async () => { + await authenticateAutomationAndMakeController({ + pic, + actor: satelliteActor, + automation + }); + + satelliteActor.setIdentity(automation.automationIdentity); + + const { list_docs } = satelliteActor; + + const docs = await list_docs('#automation-workflow', mockListParams); + + expect(docs.items_length).toEqual(1n); + + const { items } = docs; + const [[key, doc]] = items; + + const { runId, ...rest } = mockAutomationWorkflowData; + + expect(key).toEqual(`GitHub#${mockRepositoryKey.owner}/${mockRepositoryKey.name}#${runId}`); + + const data = await fromArray(doc.data); + + expect(data).toEqual(rest); + }); +}); diff --git a/src/tests/utils/automation-assertions-authenticate-tests.utils.ts b/src/tests/utils/automation-assertions-authenticate-tests.utils.ts new file mode 100644 index 0000000000..3e7d4d0a53 --- /dev/null +++ b/src/tests/utils/automation-assertions-authenticate-tests.utils.ts @@ -0,0 +1,951 @@ +import { + idlFactoryObservatory, + type ObservatoryActor, + type SatelliteActor, + type SatelliteDid +} from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + JUNO_AUTH_ERROR_AUTOMATION_NOT_CONFIGURED, + JUNO_AUTH_ERROR_OPENID_DISABLED +} from '@junobuild/errors'; +import { nanoid } from 'nanoid'; +import { GITHUB_ACTIONS_OPEN_ID_PROVIDER } from '../constants/auth-tests.constants'; +import { OBSERVATORY_ID } from '../constants/observatory-tests.constants'; +import { mockRepositoryKey } from '../mocks/automation.mocks'; +import { mockCertificateDate } from '../mocks/jwt.mocks'; +import { generateNonce } from './auth-nonce-tests.utils'; +import { assembleJwt } from './jwt-assemble-tests.utils'; +import { makeMockGitHubActionsOpenIdJwt, type MockOpenIdJwt } from './jwt-tests.utils'; +import { assertOpenIdHttpsOutcalls } from './observatory-openid-tests.utils'; +import { tick } from './pic-tests.utils'; +import { updateRateConfigNoLimit } from './rate.tests.utils'; +import { OBSERVATORY_WASM_PATH } from './setup-tests.utils'; + +export const testAutomationAuthenticate = ({ + actor: getActor, + controller: getController, + pic: getPic +}: { + actor: () => Actor; + controller: () => Ed25519KeyIdentity; + pic: () => PocketIc; +}) => { + describe('Prepare', async () => { + let pic: PocketIc; + + let observatoryActor: Actor; + + let actor: Actor; + let controller: Ed25519KeyIdentity; + + const automationController = Ed25519KeyIdentity.generate(); + + const { nonce, salt } = await generateNonce({ caller: automationController }); + + beforeAll(async () => { + pic = getPic(); + actor = getActor(); + controller = getController(); + + const { actor: obsA } = await pic.setupCanister({ + idlFactory: idlFactoryObservatory, + wasm: OBSERVATORY_WASM_PATH, + sender: controller.getPrincipal(), + targetCanisterId: OBSERVATORY_ID + }); + + observatoryActor = obsA; + observatoryActor.setIdentity(controller); + + await updateRateConfigNoLimit({ actor: observatoryActor }); + }); + + describe('Authenticate fails', async () => { + const { jwt: mockJwt } = await makeMockGitHubActionsOpenIdJwt({ + date: mockCertificateDate, + nonce + }); + + beforeAll(() => { + actor.setIdentity(automationController); + }); + + it('should fail when automation is not configured', async () => { + const { authenticate_automation } = actor; + + await expect( + authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_AUTOMATION_NOT_CONFIGURED); + }); + + it('should fail when openid configuration is not set', async () => { + const { set_automation_config, authenticate_automation } = actor; + + actor.setIdentity(controller); + + const config: SatelliteDid.SetAutomationConfig = { + openid: [], + version: [] + }; + + await set_automation_config(config); + + actor.setIdentity(automationController); + + await expect( + authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_OPENID_DISABLED); + }); + }); + + describe('Authentication', () => { + beforeAll(async () => { + const { set_automation_config } = actor; + + actor.setIdentity(controller); + + const config: SatelliteDid.SetAutomationConfig = { + openid: [ + { + providers: [ + [ + { GitHub: null }, + { + repositories: [[mockRepositoryKey, { branches: [] }]], + controller: [] + } + ] + ], + observatory_id: [] + } + ], + version: [1n] + }; + + await set_automation_config(config); + + actor.setIdentity(automationController); + }); + + describe('Errors without Jwts', async () => { + const { jwt: mockJwt, payload: mockJwtPayload } = await makeMockGitHubActionsOpenIdJwt({ + date: mockCertificateDate, + nonce + }); + + describe('Bad jwt header', () => { + it('should fail with JwtFindProvider.BadSig when JWT header is not JSON', async () => { + const { authenticate_automation } = actor; + + // not valid JSON → decode_header fails → BadSig + const badSigJwt = assembleJwt({ header: 'not json', payload: mockJwtPayload }); + + const result = await authenticate_automation({ + OpenId: { jwt: badSigJwt, salt } + }); + + if (!('Err' in result)) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtFindProvider' in PrepareAutomation)) { + return; + } + + const jfp = PrepareAutomation.JwtFindProvider; + + expect('BadSig' in jfp).toBeTruthy(); // message string not asserted, just the variant + }); + + it('should fail with JwtFindProvider.BadClaim("alg") when alg is not RS256', async () => { + const { authenticate_automation } = actor; + + const header = JSON.stringify({ + alg: 'HS256', // ← wrong on purpose + kid: 'fb9f9371d5755f3e383a40ab3a172cd8baca517f', + typ: 'JWT' + }); + + const badAlgJwt = assembleJwt({ header, payload: mockJwtPayload }); + + const result = await authenticate_automation({ + OpenId: { jwt: badAlgJwt, salt } + }); + + if (!('Err' in result)) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtFindProvider' in PrepareAutomation)) { + return; + } + + const { JwtFindProvider } = PrepareAutomation; + + expect((JwtFindProvider as { BadClaim: string }).BadClaim).toEqual('alg'); + }); + + it('should fail with JwtFindProvider.BadClaim("typ") when typ is present and not "JWT"', async () => { + const { authenticate_automation } = actor; + + const header = JSON.stringify({ + alg: 'RS256', + kid: 'fb9f9371d5755f3e383a40ab3a172cd8baca517f', + typ: 'JWS' // ← wrong on purpose + }); + + const badTypJwt = assembleJwt({ header, payload: mockJwtPayload }); + + const result = await authenticate_automation({ + OpenId: { jwt: badTypJwt, salt } + }); + + if (!('Err' in result)) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtFindProvider' in PrepareAutomation)) { + return; + } + + const { JwtFindProvider } = PrepareAutomation; + + expect((JwtFindProvider as { BadClaim: string }).BadClaim).toEqual('typ'); + }); + }); + + it('should fail if observatory has no certificate', async () => { + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('GetOrFetchJwks' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { GetOrFetchJwks } = PrepareAutomation; + + expect('CertificateNotFound' in GetOrFetchJwks).toBeTruthy(); + }); + }); + + describe('With Jwts', () => { + let mockJwks: MockOpenIdJwt['jwks']; + let mockJwt: MockOpenIdJwt['jwt']; + + beforeAll(async () => { + actor.setIdentity(controller); + + const { start_openid_monitoring } = observatoryActor; + + await start_openid_monitoring(GITHUB_ACTIONS_OPEN_ID_PROVIDER); + + actor.setIdentity(automationController); + }); + + const generateJwtCertificate = async ({ + advanceTime, + refreshJwts = true, + kid + }: { + advanceTime?: number; + refreshJwts?: boolean; + kid?: string; + }) => { + await pic.advanceTime(advanceTime ?? 1000 * 60 * 15); // Observatory refresh every 15min + + await tick(pic); + + const now = await pic.getTime(); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce, + kid + }); + + mockJwks = jwks; + mockJwt = jwt; + + if (!refreshJwts) { + return; + } + + // Refresh certificate in Observatory + await assertOpenIdHttpsOutcalls({ pic, jwks: mockJwks, method: 'github_actions' }); + }; + + it('should fail at authenticating because fetching Jwts is disallowed (cooldown period)', async () => { + await generateJwtCertificate({ advanceTime: 1000 }); + + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('GetOrFetchJwks' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { GetOrFetchJwks } = PrepareAutomation; + + expect('KeyNotFoundCooldown' in GetOrFetchJwks).toBeTruthy(); + }); + + describe('Kid', () => { + const kid = nanoid(); + + beforeEach(async () => { + // Generate for Kid and update certificate in Observatory + await generateJwtCertificate({ advanceTime: 1000 * 60 * 15, refreshJwts: true, kid }); + }); + + it('should fetch certificate but fail with kid not found', async () => { + await generateJwtCertificate({ advanceTime: 1000 * 60, refreshJwts: false }); + + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('GetOrFetchJwks' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { GetOrFetchJwks } = PrepareAutomation; + + expect('KeyNotFound' in GetOrFetchJwks).toBeTruthy(); + }); + + it('should fetch certificate but fail with invalid signature', async () => { + await generateJwtCertificate({ advanceTime: 1000 * 60, refreshJwts: false, kid }); + + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { JwtVerify } = PrepareAutomation; + + expect('BadSig' in JwtVerify).toBeTruthy(); + expect((JwtVerify as { BadSig: string }).BadSig).toEqual('InvalidSignature'); + }); + }); + + it('should authenticate user', async () => { + await generateJwtCertificate({}); + + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + expect('Ok' in result).toBeTruthy(); + }); + + it('should fail at authenticating attacker', async () => { + await generateJwtCertificate({}); + + const attacker = Ed25519KeyIdentity.generate(); + actor.setIdentity(attacker); + + const { authenticate_automation } = actor; + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { JwtVerify } = PrepareAutomation; + + expect('BadClaim' in JwtVerify).toBeTruthy(); + expect((JwtVerify as { BadClaim: string }).BadClaim).toEqual('nonce'); + + actor.setIdentity(automationController); + }); + + it('should fail when salt is wrong for the same user (nonce mismatch)', async () => { + await generateJwtCertificate({}); + + const wrongSalt = crypto.getRandomValues(new Uint8Array(32)); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt: mockJwt, salt: wrongSalt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + expect((PrepareAutomation.JwtVerify as { BadClaim: string }).BadClaim).toEqual('nonce'); + }); + + it('should fail when token is replayed after 10 minutes (iat_expired)', async () => { + await generateJwtCertificate({}); + + await pic.advanceTime(10 * 60_000 + 1_000); + await tick(pic); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt: mockJwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + expect((PrepareAutomation.JwtVerify as { BadClaim: string }).BadClaim).toEqual( + 'iat_expired' + ); + }); + + it('should fail when audience does not match', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce: 'wrong-nonce' // nonce is passed as aud + }); + + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + expect((PrepareAutomation.JwtVerify as { BadClaim: string }).BadClaim).toEqual('nonce'); + }); + + it('should pass jwt verification if iat is slightly in the future (within skew) but controller already exists', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const base = await pic.getTime(); + const future60s = new Date(base + 60_000); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: future60s, + nonce + }); + + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + expect('ControllerAlreadyExists' in PrepareAutomation).toBeTruthy(); + }); + + it('should fail when iat is beyond future skew', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const base = await pic.getTime(); + const future3min = new Date(base + 3 * 60_000); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: future3min, + nonce + }); + + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + expect((PrepareAutomation.JwtVerify as { BadClaim: string }).BadClaim).toEqual( + 'iat_future' + ); + }); + + it('should fail when iat is older than 10 minutes', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const base = await pic.getTime(); + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(base), + nonce + }); + + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + await pic.advanceTime(10 * 60_000 + 1_000); + await tick(pic); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + expect((PrepareAutomation.JwtVerify as { BadClaim: string }).BadClaim).toEqual( + 'iat_expired' + ); + }); + + it('should fail when JWT header has no kid', async () => { + const now = await pic.getTime(); + + const { payload } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce + }); + + const headerNoKid = JSON.stringify({ alg: 'RS256' }); + const badJwt = assembleJwt({ header: headerNoKid, payload }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt: badJwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('GetOrFetchJwks' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { GetOrFetchJwks } = PrepareAutomation; + + expect('MissingKid' in GetOrFetchJwks).toBeTruthy(); + }); + + it('should fail when JWKS key type is not RSA (WrongKeyType)', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + const { jwt, kid } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce + }); + + const ecJwks = { + keys: [ + { + kty: 'EC', + alg: 'ES256', + kid, + crv: 'P-256', + x: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + y: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + } + ] + } as unknown as MockOpenIdJwt['jwks']; + + await assertOpenIdHttpsOutcalls({ pic, jwks: ecJwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { JwtVerify } = PrepareAutomation; + + expect('WrongKeyType' in JwtVerify).toBeTruthy(); + }); + + it('should fail when nbf is in the future', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + const base = Math.floor(now / 1000); + + const { jwks, kid } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce + }); + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { owner, name } = mockRepositoryKey; + + const payload = { + iss: 'https://token.actions.githubusercontent.com', + sub: `repo:${owner}/${name}:ref:refs/heads/main`, + aud: nonce, + iat: base, + exp: base + 3600, + nbf: base + 300, + jti: nanoid(), + ref: 'refs/heads/main', + repository_owner: owner, + run_id: '21776509605', + run_attempt: '1', + repository: `${owner}/${name}`, + run_number: '1' + } as const; + + const header = JSON.stringify({ alg: 'RS256', kid, typ: 'JWT' }); + const badNbfJwt = assembleJwt({ header, payload }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt: badNbfJwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('JwtVerify' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { JwtVerify } = PrepareAutomation; + + expect('BadSig' in JwtVerify).toBeTruthy(); + }); + }); + }); + }); +}; diff --git a/src/tests/utils/automation-assertions-config-openid-tests.utils.ts b/src/tests/utils/automation-assertions-config-openid-tests.utils.ts new file mode 100644 index 0000000000..7fbf57891d --- /dev/null +++ b/src/tests/utils/automation-assertions-config-openid-tests.utils.ts @@ -0,0 +1,176 @@ +import { + idlFactoryObservatory, + type ObservatoryActor, + type SatelliteActor, + type SatelliteDid +} from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import type { Principal } from '@icp-sdk/core/principal'; +import { GITHUB_ACTIONS_OPEN_ID_PROVIDER } from '../constants/auth-tests.constants'; +import { mockRepositoryKey } from '../mocks/automation.mocks'; +import { generateNonce } from './auth-nonce-tests.utils'; +import { makeMockGitHubActionsOpenIdJwt } from './jwt-tests.utils'; +import { assertOpenIdHttpsOutcalls } from './observatory-openid-tests.utils'; +import { tick } from './pic-tests.utils'; +import { updateRateConfigNoLimit } from './rate.tests.utils'; +import { OBSERVATORY_WASM_PATH } from './setup-tests.utils'; + +export const testAutomationConfigObservatory = ({ + actor: getActor, + controller: getController, + pic: getPic +}: { + actor: () => Actor; + controller: () => Ed25519KeyIdentity; + pic: () => PocketIc; +}) => { + describe('Observatory', async () => { + let observatoryActor: Actor; + let observatoryCanisterId: Principal; + let mockJwt: string; + + const identity = Ed25519KeyIdentity.generate(); + + const { nonce, salt } = await generateNonce({ caller: identity }); + + const configTargetObservatory = async ({ + observatoryId, + version + }: { + observatoryId?: Principal; + version?: bigint; + }) => { + const actor = getActor(); + + const { set_automation_config } = actor; + + actor.setIdentity(getController()); + + const config: SatelliteDid.SetAutomationConfig = { + openid: [ + { + providers: [ + [ + { GitHub: null }, + { + repositories: [[mockRepositoryKey, { branches: [] }]], + controller: [] + } + ] + ], + observatory_id: toNullable(observatoryId) + } + ], + version: toNullable(version) + }; + + await set_automation_config(config); + await updateRateConfigNoLimit({ actor: observatoryActor }); + + actor.setIdentity(identity); + }; + + beforeAll(async () => { + const pic = getPic(); + const controller = getController(); + + const { actor: obsA, canisterId: obsC } = await pic.setupCanister({ + idlFactory: idlFactoryObservatory, + wasm: OBSERVATORY_WASM_PATH, + sender: controller.getPrincipal() + }); + + observatoryActor = obsA; + observatoryCanisterId = obsC; + + observatoryActor.setIdentity(controller); + + const { start_openid_monitoring } = observatoryActor; + await start_openid_monitoring(GITHUB_ACTIONS_OPEN_ID_PROVIDER); + + await pic.advanceTime(1000 * 60 * 15); // Observatory refresh every 15min + await tick(pic); + + const now = await pic.getTime(); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce + }); + + // Refresh certificate in Observatory + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + mockJwt = jwt; + }); + + it('should fail if default observatory is targeted', async () => { + await configTargetObservatory({}); + + const { authenticate_automation } = getActor(); + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('PrepareAutomation' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { PrepareAutomation } = Err; + + if (!('GetOrFetchJwks' in PrepareAutomation)) { + expect(true).toBeFalsy(); + + return; + } + + const { GetOrFetchJwks } = PrepareAutomation; + + if (!('FetchFailed' in GetOrFetchJwks)) { + expect(true).toBeFalsy(); + + return; + } + + const { FetchFailed } = GetOrFetchJwks; + + expect(FetchFailed).toEqual( + 'Call failed: CallRejected(CallRejected { raw_reject_code: 3, reject_message: "No route to canister klbfr-lqaaa-aaaak-qbwsa-cai" })' + ); + }); + + it('should succeed with custom observatory jwts', async () => { + await configTargetObservatory({ version: 1n, observatoryId: observatoryCanisterId }); + + await getPic().advanceTime(1000 * 30); // 30s for cooldown guard + await tick(getPic()); + + const { authenticate_automation } = getActor(); + + const result = await authenticate_automation({ + OpenId: { + jwt: mockJwt, + salt + } + }); + + expect('Ok' in result).toBeTruthy(); + }); + }); +}; diff --git a/src/tests/utils/automation-assertions-token-tests.utils.ts b/src/tests/utils/automation-assertions-token-tests.utils.ts new file mode 100644 index 0000000000..7c114501bb --- /dev/null +++ b/src/tests/utils/automation-assertions-token-tests.utils.ts @@ -0,0 +1,196 @@ +import { + idlFactoryObservatory, + type ObservatoryActor, + type SatelliteActor, + type SatelliteDid +} from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + JUNO_AUTOMATION_TOKEN_ERROR_MISSING_JTI, + JUNO_AUTOMATION_TOKEN_ERROR_TOKEN_REUSED +} from '@junobuild/errors'; +import { nanoid } from 'nanoid'; +import { GITHUB_ACTIONS_OPEN_ID_PROVIDER } from '../constants/auth-tests.constants'; +import { OBSERVATORY_ID } from '../constants/observatory-tests.constants'; +import { mockRepositoryKey } from '../mocks/automation.mocks'; +import { generateNonce } from './auth-nonce-tests.utils'; +import { makeMockGitHubActionsOpenIdJwt } from './jwt-tests.utils'; +import { assertOpenIdHttpsOutcalls } from './observatory-openid-tests.utils'; +import { tick } from './pic-tests.utils'; +import { updateRateConfigNoLimit } from './rate.tests.utils'; +import { OBSERVATORY_WASM_PATH } from './setup-tests.utils'; + +export const testAutomationToken = ({ + actor: getActor, + controller: getController, + pic: getPic +}: { + actor: () => Actor; + controller: () => Ed25519KeyIdentity; + pic: () => PocketIc; +}) => { + describe('Token', async () => { + let pic: PocketIc; + + let observatoryActor: Actor; + + let actor: Actor; + let controller: Ed25519KeyIdentity; + + const automationController = Ed25519KeyIdentity.generate(); + + const { nonce, salt } = await generateNonce({ caller: automationController }); + + beforeAll(async () => { + pic = getPic(); + actor = getActor(); + controller = getController(); + + const { actor: obsA } = await pic.setupCanister({ + idlFactory: idlFactoryObservatory, + wasm: OBSERVATORY_WASM_PATH, + sender: controller.getPrincipal(), + targetCanisterId: OBSERVATORY_ID + }); + + observatoryActor = obsA; + observatoryActor.setIdentity(controller); + + await updateRateConfigNoLimit({ actor: observatoryActor }); + }); + + describe('Jti', () => { + beforeAll(async () => { + const { set_automation_config } = actor; + + actor.setIdentity(controller); + + const config: SatelliteDid.SetAutomationConfig = { + openid: [ + { + providers: [ + [ + { GitHub: null }, + { + repositories: [[mockRepositoryKey, { branches: [] }]], + controller: [] + } + ] + ], + observatory_id: [] + } + ], + version: [1n] + }; + + await set_automation_config(config); + + actor.setIdentity(automationController); + }); + + describe('With Jwts', () => { + beforeAll(async () => { + actor.setIdentity(controller); + + const { start_openid_monitoring } = observatoryActor; + + await start_openid_monitoring(GITHUB_ACTIONS_OPEN_ID_PROVIDER); + + actor.setIdentity(automationController); + }); + + it('should fail when jti is missing from JWT', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce, + jti: null // No jti in the jwt + }); + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + const result = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result; + + if (!('SaveUniqueJtiToken' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + const { SaveUniqueJtiToken } = Err; + + expect(SaveUniqueJtiToken).toEqual(JUNO_AUTOMATION_TOKEN_ERROR_MISSING_JTI); + }); + + it('should fail when jti is reused (replay attack)', async () => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + + const jti = nanoid(); + + const { jwks, jwt } = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce, + jti + }); + + await assertOpenIdHttpsOutcalls({ pic, jwks, method: 'github_actions' }); + + const { authenticate_automation } = actor; + + const result1 = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + expect('Ok' in result1).toBeTruthy(); + + // Remove controller to avoid ControllerAlreadyExists checks in next step + actor.setIdentity(controller); + + const { del_controllers } = actor; + await del_controllers({ controllers: [automationController.getPrincipal()] }); + + actor.setIdentity(automationController); + + // Then try again with same jti + const result2 = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Ok' in result2) { + expect(true).toBeFalsy(); + + return; + } + + const { Err } = result2; + + if (!('SaveUniqueJtiToken' in Err)) { + expect(true).toBeFalsy(); + + return; + } + + expect(Err.SaveUniqueJtiToken).toEqual(JUNO_AUTOMATION_TOKEN_ERROR_TOKEN_REUSED); + }); + }); + }); + }); +}; diff --git a/src/tests/utils/automation-controller-tests.utils.ts b/src/tests/utils/automation-controller-tests.utils.ts new file mode 100644 index 0000000000..f8ada131d2 --- /dev/null +++ b/src/tests/utils/automation-controller-tests.utils.ts @@ -0,0 +1,86 @@ +import type { SatelliteActor } from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { fromNullable } from '@dfinity/utils'; +import type { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { JUNO_AUTH_ERROR_NOT_ADMIN_CONTROLLER } from '@junobuild/errors'; +import type { TestAutomation } from './automation-tests.utils'; +import { makeMockGitHubActionsOpenIdJwt } from './jwt-tests.utils'; +import { assertOpenIdHttpsOutcalls } from './observatory-openid-tests.utils'; +import { tick } from './pic-tests.utils'; + +export const authenticateAutomationAndMakeController = async ({ + pic, + automation: { nonce, salt }, + actor +}: { + pic: PocketIc; + automation: TestAutomation; + actor: Actor; +}): Promise => { + await pic.advanceTime(15 * 60_000); + await tick(pic); + + const now = await pic.getTime(); + + const mockJwt = await makeMockGitHubActionsOpenIdJwt({ + date: new Date(now), + nonce + }); + + const { jwks, jwt } = mockJwt; + + await assertOpenIdHttpsOutcalls({ + pic, + jwks, + method: 'github_actions' + }); + + const { authenticate_automation } = actor; + + const prepareAutomation = await authenticate_automation({ + OpenId: { jwt, salt } + }); + + if ('Err' in prepareAutomation) { + expect(true).toBeFalsy(); + + throw new Error('Unreachable'); + } +}; + +export const assertAutomationController = async ({ + controller, + automationIdentity, + satelliteActor +}: { + controller: Ed25519KeyIdentity; + automationIdentity: Ed25519KeyIdentity; + satelliteActor: Actor; +}) => { + satelliteActor.setIdentity(automationIdentity); + + const { count_collection_docs, list_controllers } = satelliteActor; + + const count = await count_collection_docs('#automation-workflow'); + + expect(count).toEqual(1n); + + await expect(list_controllers()).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_ADMIN_CONTROLLER); + + satelliteActor.setIdentity(controller); + + const { list_controllers: admin_list_controllers } = satelliteActor; + + const controllers = await admin_list_controllers(); + + const automatedController = controllers.find( + ([p, _]) => p.toText() === automationIdentity.getPrincipal().toText() + ); + + expect(automatedController).not.toBeUndefined(); + expect('Write' in (automatedController?.[1].scope ?? {})).toBeTruthy(); + + const kind = fromNullable(automatedController?.[1].kind ?? []); + + expect('Automation' in (kind ?? {})).toBeTruthy(); +}; diff --git a/src/tests/utils/automation-tests.utils.ts b/src/tests/utils/automation-tests.utils.ts new file mode 100644 index 0000000000..dcff859f80 --- /dev/null +++ b/src/tests/utils/automation-tests.utils.ts @@ -0,0 +1,138 @@ +import { + idlFactoryObservatory, + type ObservatoryActor, + type SatelliteActor, + type SatelliteDid +} from '$declarations'; +import type { Actor, PocketIc } from '@dfinity/pic'; +import { toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import type { Principal } from '@icp-sdk/core/principal'; +import { GITHUB_ACTIONS_OPEN_ID_PROVIDER } from '../constants/auth-tests.constants'; +import { OBSERVATORY_ID } from '../constants/observatory-tests.constants'; +import { mockRepositoryKey } from '../mocks/automation.mocks'; +import { mockCertificateDate } from '../mocks/jwt.mocks'; +import { generateNonce } from './auth-nonce-tests.utils'; +import { updateRateConfigNoLimit } from './rate.tests.utils'; +import { setupSatelliteStock } from './satellite-tests.utils'; +import { OBSERVATORY_WASM_PATH } from './setup-tests.utils'; + +export interface TestAutomation { + automationIdentity: Ed25519KeyIdentity; + nonce: string; + salt: Uint8Array; +} + +interface SetupAutomation { + pic: PocketIc; + controller: Ed25519KeyIdentity; + observatory: { canisterId: Principal; actor: Actor }; + automation: TestAutomation; +} + +export const setupSatelliteAutomation = async ({ + controllerConfig +}: { + controllerConfig?: SatelliteDid.OpenIdAutomationProviderControllerConfig; +} = {}): Promise< + SetupAutomation & { + satellite: { canisterId: Principal; actor: Actor }; + } +> => { + const { + actor: a, + pic: p, + controller: cO, + canisterId: cId + } = await setupSatelliteStock({ + dateTime: mockCertificateDate, + withIndexHtml: false, + memory: { Heap: null } + }); + + const pic = p; + const controller = cO; + const satelliteActor = a; + const satelliteCanisterId = cId; + + const common = await setupAutomation({ + pic, + controller, + actor: satelliteActor, + controllerConfig + }); + + return { + ...common, + satellite: { canisterId: satelliteCanisterId, actor: satelliteActor } + }; +}; + +const setupAutomation = async ({ + pic, + controller, + actor, + controllerConfig +}: { + pic: PocketIc; + controller: Ed25519KeyIdentity; + actor: Actor; + controllerConfig?: SatelliteDid.OpenIdAutomationProviderControllerConfig; +}): Promise => { + const automationIdentity = Ed25519KeyIdentity.generate(); + + const { nonce, salt } = await generateNonce({ caller: automationIdentity }); + + const { actor: obsA } = await pic.setupCanister({ + idlFactory: idlFactoryObservatory, + wasm: OBSERVATORY_WASM_PATH, + sender: controller.getPrincipal(), + targetCanisterId: OBSERVATORY_ID + }); + + const observatoryActor = obsA; + observatoryActor.setIdentity(controller); + + // Enable authentication with OpenID + actor.setIdentity(controller); + + const config: SatelliteDid.SetAutomationConfig = { + openid: [ + { + providers: [ + [ + { GitHub: null }, + { + repositories: [[mockRepositoryKey, { branches: [] }]], + controller: toNullable(controllerConfig) + } + ] + ], + observatory_id: [] + } + ], + version: [1n] + }; + + const { set_automation_config } = actor; + await set_automation_config(config); + + // Start fetching OpenID Jwts in Observatory + const { start_openid_monitoring } = observatoryActor; + await start_openid_monitoring(GITHUB_ACTIONS_OPEN_ID_PROVIDER); + + await updateRateConfigNoLimit({ actor: observatoryActor }); + + actor.setIdentity(automationIdentity); + + return { + pic, + controller, + observatory: { actor: observatoryActor, canisterId: OBSERVATORY_ID }, + automation: { + nonce, + salt, + automationIdentity + } + }; +}; diff --git a/src/tests/utils/jwt-tests.utils.ts b/src/tests/utils/jwt-tests.utils.ts index 65970d64e3..8e332e9b59 100644 --- a/src/tests/utils/jwt-tests.utils.ts +++ b/src/tests/utils/jwt-tests.utils.ts @@ -1,5 +1,6 @@ import { exportJWK, generateKeyPair, SignJWT, type JWK, type JWTPayload } from 'jose'; import { nanoid } from 'nanoid'; +import { mockAutomationWorkflowData, mockRepositoryKey } from '../mocks/automation.mocks'; export interface MockOpenIdJwt { jwks: { keys: Required[] }; @@ -74,6 +75,43 @@ export const makeMockGitHubAuthOpenIdJwt = async ({ }); }; +export const makeMockGitHubActionsOpenIdJwt = async ({ + nonce, + jti, + date, + ...rest +}: { + date: Date; + nonce: string; + kid?: string; + jti?: string | null; +}): Promise => { + const timestamp = Math.floor(date.getTime() / 1000); + + const { owner, name } = mockRepositoryKey; + const { runId, runNumber, runAttempt, ref } = mockAutomationWorkflowData; + + const payload = { + iss: 'https://token.actions.githubusercontent.com', + sub: `repo:${owner}/${name}:ref:refs/heads/main`, + aud: nonce, + iat: timestamp - 10, + exp: timestamp + 3600, + ...(jti !== null && { jti: jti ?? nanoid() }), + ref, + repository_owner: owner, + run_id: runId, + run_attempt: runAttempt, + repository: `${owner}/${name}`, + run_number: runNumber + } as const; + + return await makeMockOpenIdJwt({ + payload, + ...rest + }); +}; + const makeMockOpenIdJwt = async ({ kid, payload