diff --git a/src/declarations/observatory/observatory.did.d.ts b/src/declarations/observatory/observatory.did.d.ts index 760bcea78a..e431da6fe8 100644 --- a/src/declarations/observatory/observatory.did.d.ts +++ b/src/declarations/observatory/observatory.did.d.ts @@ -108,7 +108,7 @@ export interface OpenIdCertificate { created_at: bigint; version: [] | [bigint]; } -export type OpenIdProvider = { Google: null } | { GitHubAuth: null }; +export type OpenIdProvider = { GitHubActions: null } | { Google: null } | { GitHubAuth: null }; export interface RateConfig { max_tokens: bigint; time_per_token_ns: bigint; diff --git a/src/declarations/observatory/observatory.factory.certified.did.js b/src/declarations/observatory/observatory.factory.certified.did.js index 4a483ecf67..bc79899ec1 100644 --- a/src/declarations/observatory/observatory.factory.certified.did.js +++ b/src/declarations/observatory/observatory.factory.certified.did.js @@ -21,6 +21,7 @@ export const idlFactory = ({ IDL }) => { failed: IDL.Nat64 }); const OpenIdProvider = IDL.Variant({ + GitHubActions: IDL.Null, Google: IDL.Null, GitHubAuth: IDL.Null }); diff --git a/src/declarations/observatory/observatory.factory.did.js b/src/declarations/observatory/observatory.factory.did.js index f8f8ac2190..5d148da976 100644 --- a/src/declarations/observatory/observatory.factory.did.js +++ b/src/declarations/observatory/observatory.factory.did.js @@ -21,6 +21,7 @@ export const idlFactory = ({ IDL }) => { failed: IDL.Nat64 }); const OpenIdProvider = IDL.Variant({ + GitHubActions: IDL.Null, Google: IDL.Null, GitHubAuth: IDL.Null }); diff --git a/src/declarations/observatory/observatory.factory.did.mjs b/src/declarations/observatory/observatory.factory.did.mjs index f8f8ac2190..5d148da976 100644 --- a/src/declarations/observatory/observatory.factory.did.mjs +++ b/src/declarations/observatory/observatory.factory.did.mjs @@ -21,6 +21,7 @@ export const idlFactory = ({ IDL }) => { failed: IDL.Nat64 }); const OpenIdProvider = IDL.Variant({ + GitHubActions: IDL.Null, Google: IDL.Null, GitHubAuth: IDL.Null }); diff --git a/src/declarations/satellite/satellite.did.d.ts b/src/declarations/satellite/satellite.did.d.ts index 6c29afed4a..0ee8bfc913 100644 --- a/src/declarations/satellite/satellite.did.d.ts +++ b/src/declarations/satellite/satellite.did.d.ts @@ -34,6 +34,12 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateControllerArgs = { + OpenId: OpenIdAuthenticateControllerArgs; +}; +export type AuthenticateControllerResultResponse = + | { Ok: null } + | { Err: AuthenticationControllerError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; @@ -56,6 +62,9 @@ export interface AuthenticationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdDelegationProvider, OpenIdAuthProviderConfig]>; } +export type AuthenticationControllerError = + | { RegisterController: string } + | { VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError }; export type AuthenticationError = | { PrepareDelegation: PrepareDelegationError; @@ -64,6 +73,7 @@ export type AuthenticationError = export interface AuthenticationRules { allowed_callers: Array; } +export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { batch_id: bigint; @@ -269,6 +279,13 @@ export interface OpenIdAuthProviderDelegationConfig { targets: [] | [Array]; max_time_to_live: [] | [bigint]; } +export interface OpenIdAuthenticateControllerArgs { + jwt: string; + metadata: Array<[string, string]>; + scope: AutomationScope; + max_time_to_live: [] | [bigint]; + controller_id: Principal; +} export type OpenIdDelegationProvider = { GitHub: null } | { Google: null }; export interface OpenIdGetDelegationArgs { jwt: string; @@ -441,8 +458,18 @@ export interface UploadChunk { export interface UploadChunkResult { chunk_id: bigint; } +export type VerifyOpenidAutomationCredentialsError = + | { + GetCachedJwks: null; + } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError }; export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_controller: ActorMethod< + [AuthenticateControllerArgs], + AuthenticateControllerResultResponse + >; 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 3926f90a01..1d81905177 100644 --- a/src/declarations/satellite/satellite.factory.certified.did.js +++ b/src/declarations/satellite/satellite.factory.certified.did.js @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -452,6 +479,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 74a3f16d91..0f3deb06b6 100644 --- a/src/declarations/satellite/satellite.factory.did.js +++ b/src/declarations/satellite/satellite.factory.did.js @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -452,6 +479,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 74a3f16d91..0f3deb06b6 100644 --- a/src/declarations/satellite/satellite.factory.did.mjs +++ b/src/declarations/satellite/satellite.factory.did.mjs @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -452,6 +479,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 6c29afed4a..0ee8bfc913 100644 --- a/src/declarations/sputnik/sputnik.did.d.ts +++ b/src/declarations/sputnik/sputnik.did.d.ts @@ -34,6 +34,12 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateControllerArgs = { + OpenId: OpenIdAuthenticateControllerArgs; +}; +export type AuthenticateControllerResultResponse = + | { Ok: null } + | { Err: AuthenticationControllerError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; @@ -56,6 +62,9 @@ export interface AuthenticationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdDelegationProvider, OpenIdAuthProviderConfig]>; } +export type AuthenticationControllerError = + | { RegisterController: string } + | { VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError }; export type AuthenticationError = | { PrepareDelegation: PrepareDelegationError; @@ -64,6 +73,7 @@ export type AuthenticationError = export interface AuthenticationRules { allowed_callers: Array; } +export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { batch_id: bigint; @@ -269,6 +279,13 @@ export interface OpenIdAuthProviderDelegationConfig { targets: [] | [Array]; max_time_to_live: [] | [bigint]; } +export interface OpenIdAuthenticateControllerArgs { + jwt: string; + metadata: Array<[string, string]>; + scope: AutomationScope; + max_time_to_live: [] | [bigint]; + controller_id: Principal; +} export type OpenIdDelegationProvider = { GitHub: null } | { Google: null }; export interface OpenIdGetDelegationArgs { jwt: string; @@ -441,8 +458,18 @@ export interface UploadChunk { export interface UploadChunkResult { chunk_id: bigint; } +export type VerifyOpenidAutomationCredentialsError = + | { + GetCachedJwks: null; + } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError }; export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_controller: ActorMethod< + [AuthenticateControllerArgs], + AuthenticateControllerResultResponse + >; 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 3926f90a01..1d81905177 100644 --- a/src/declarations/sputnik/sputnik.factory.certified.did.js +++ b/src/declarations/sputnik/sputnik.factory.certified.did.js @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -452,6 +479,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 74a3f16d91..0f3deb06b6 100644 --- a/src/declarations/sputnik/sputnik.factory.did.js +++ b/src/declarations/sputnik/sputnik.factory.did.js @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -452,6 +479,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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..2f8f284a3c --- /dev/null +++ b/src/libs/auth/src/automation/impls.rs @@ -0,0 +1,27 @@ +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::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 new file mode 100644 index 0000000000..4db9a9240a --- /dev/null +++ b/src/libs/auth/src/automation/mod.rs @@ -0,0 +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..cfb69840aa --- /dev/null +++ b/src/libs/auth/src/automation/prepare.rs @@ -0,0 +1,34 @@ +use crate::automation::types::{ + PrepareAutomationError, PrepareAutomationResult, PreparedAutomation, + PreparedControllerAutomation, +}; +use crate::automation::utils::duration::build_expiration; +use crate::automation::utils::scope::build_scope; +use crate::openid::types::provider::OpenIdAutomationProvider; +use crate::strategies::AuthHeapStrategy; +use junobuild_shared::segments::controllers::assert_controllers; +use junobuild_shared::types::state::ControllerId; + +pub fn openid_prepare_automation( + controller_id: &ControllerId, + provider: &OpenIdAutomationProvider, + auth_heap: &impl AuthHeapStrategy, +) -> PrepareAutomationResult { + let controllers: [ControllerId; 1] = [controller_id.clone()]; + + assert_controllers(&controllers).map_err(PrepareAutomationError::InvalidController)?; + + // TODO: Assert do not exist + + let expires_at = build_expiration(provider, auth_heap); + + let scope = build_scope(provider, auth_heap); + + let controller: PreparedControllerAutomation = PreparedControllerAutomation { + id: controller_id.clone(), + expires_at, + scope, + }; + + Ok(PreparedAutomation { controller }) +} diff --git a/src/libs/auth/src/automation/types.rs b/src/libs/auth/src/automation/types.rs new file mode 100644 index 0000000000..f78c00889b --- /dev/null +++ b/src/libs/auth/src/automation/types.rs @@ -0,0 +1,43 @@ +use crate::delegation::types::SessionKey; +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::interface::SetController; +use junobuild_shared::types::state::ControllerId; +use serde::Serialize; + +#[derive(CandidType, Serialize, Deserialize)] +pub struct OpenIdPrepareAutomationArgs { + pub jwt: String, + pub controller_id: ControllerId, +} + +pub type PrepareAutomationResult = Result; + +#[derive(CandidType, Serialize, Deserialize)] +pub struct PreparedAutomation { + pub controller: PreparedControllerAutomation, +} + +#[derive(CandidType, Serialize, Deserialize)] +pub struct PreparedControllerAutomation { + pub id: ControllerId, + 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 { + InvalidController(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/lib.rs b/src/libs/auth/src/lib.rs index 968d88c334..f753a00059 100644 --- a/src/libs/auth/src/lib.rs +++ b/src/libs/auth/src/lib.rs @@ -1,3 +1,4 @@ +pub mod automation; pub mod delegation; pub mod openid; pub mod profile; 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..22cab64769 --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/impls.rs @@ -0,0 +1,26 @@ +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 + } +} 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..35c6668fcf --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/mod.rs @@ -0,0 +1,5 @@ +mod impls; +pub mod types; +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/verify.rs b/src/libs/auth/src/openid/credentials/automation/verify.rs new file mode 100644 index 0000000000..e563e4a7bc --- /dev/null +++ b/src/libs/auth/src/openid/credentials/automation/verify.rs @@ -0,0 +1,361 @@ +use crate::openid::credentials::automation::types::interface::OpenIdAutomationCredential; +use crate::openid::credentials::automation::types::token::AutomationClaims; +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::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, + 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 jwks = get_or_refresh_jwks(&provider, jwt, auth_heap) + .await + .map_err(VerifyOpenidCredentialsError::GetOrFetchJwks)?; + + verify_openid_credentials(jwt, &jwks, &automation_provider, &config) +} + +fn verify_openid_credentials( + jwt: &str, + jwks: &Jwks, + provider: &OpenIdAutomationProvider, + config: &OpenIdAutomationProviderConfig, +) -> VerifyOpenIdAutomationCredentialsResult { + let assert_audience = |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 assert_custom = |claims: &AutomationClaims| -> Result<(), JwtVerifyError> { + // No custom assertion for automation. Replay attack protection must notably be implemented by consumer. + + Ok(()) + }; + + let token = verify_openid_jwt( + jwt, + provider.issuers(), + &jwks.keys, + &assert_audience, + &assert_custom, + ) + .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::state::types::automation::{ + OpenIdAutomationProviderConfig, OpenIdAutomationRepositories, + OpenIdAutomationRepositoryConfig, RepositoryKey, + }; + 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_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 claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/main".into(), + aud: "https://github.com/octo-org".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + nonce: 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); + + 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_unauthorized_repository() { + let now = now_secs(); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:other-org/other-repo:ref:refs/heads/main".into(), + aud: "https://github.com/other-org".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + nonce: 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); + + 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 claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/feature".into(), + aud: "https://github.com/octo-org".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + nonce: 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); + + 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 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: "https://github.com/octo-org".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + nonce: 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); + + assert!(result.is_ok()); + } + + #[test] + fn rejects_missing_repository_claim() { + let now = now_secs(); + + let claims = AutomationClaims { + iss: ISS_GITHUB_ACTIONS.into(), + sub: "repo:octo-org/octo-repo:ref:refs/heads/main".into(), + aud: "https://github.com/octo-org".into(), + iat: Some(now), + exp: Some(now + 600), + nbf: None, + nonce: 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); + + 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/verify.rs b/src/libs/auth/src/openid/credentials/delegation/verify.rs index fd65d9ca58..dd655553a5 100644 --- a/src/libs/auth/src/openid/credentials/delegation/verify.rs +++ b/src/libs/auth/src/openid/credentials/delegation/verify.rs @@ -15,6 +15,9 @@ use crate::strategies::AuthHeapStrategy; type VerifyOpenIdDelegationCredentialsResult = Result<(OpenIdDelegationCredential, OpenIdDelegationProvider), VerifyOpenidCredentialsError>; +/// Verifies delegation OIDC credentials (e.g. Google, GitHub) and returns the credential. +/// +/// Replay protection is enforced via nonce validation using the provided salt and caller(). pub async fn verify_openid_credentials_with_jwks_renewal( jwt: &str, salt: &Salt, 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/impls.rs b/src/libs/auth/src/openid/impls.rs index cd53a4cf92..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}; @@ -13,6 +15,7 @@ impl OpenIdProvider { // Swap for local development with the Juno API: // http://host.docker.internal:3000/v1/auth/certs Self::GitHubAuth => "https://api.juno.build/v1/auth/certs", + Self::GitHubActions => "https://token.actions.githubusercontent.com/.well-known/jwks", } } @@ -20,6 +23,7 @@ impl OpenIdProvider { match self { OpenIdProvider::Google => &["https://accounts.google.com", "accounts.google.com"], OpenIdProvider::GitHubAuth => &["https://api.juno.build/auth/github"], + OpenIdProvider::GitHubActions => &["https://token.actions.githubusercontent.com"], } } } @@ -55,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 @@ -98,6 +138,7 @@ impl Display for OpenIdProvider { match self { OpenIdProvider::Google => write!(f, "Google"), OpenIdProvider::GitHubAuth => write!(f, "GitHub"), + OpenIdProvider::GitHubActions => write!(f, "GitHub Actions"), } } } @@ -116,6 +157,10 @@ mod tests { OpenIdProvider::GitHubAuth.jwks_url(), "https://api.juno.build/v1/auth/certs" ); + assert_eq!( + OpenIdProvider::GitHubActions.jwks_url(), + "https://token.actions.githubusercontent.com/.well-known/jwks" + ); } #[test] @@ -128,6 +173,10 @@ mod tests { OpenIdProvider::GitHubAuth.issuers(), &["https://api.juno.build/auth/github"] ); + assert_eq!( + OpenIdProvider::GitHubActions.issuers(), + &["https://token.actions.githubusercontent.com"] + ); } #[test] @@ -166,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![] }; @@ -192,5 +265,9 @@ mod tests { fn test_openid_provider_display() { assert_eq!(format!("{}", OpenIdProvider::Google), "Google"); assert_eq!(format!("{}", OpenIdProvider::GitHubAuth), "GitHub"); + assert_eq!( + format!("{}", OpenIdProvider::GitHubActions), + "GitHub Actions" + ); } } diff --git a/src/libs/auth/src/openid/jwt/verify.rs b/src/libs/auth/src/openid/jwt/verify.rs index 7bec4648cb..b402c27fac 100644 --- a/src/libs/auth/src/openid/jwt/verify.rs +++ b/src/libs/auth/src/openid/jwt/verify.rs @@ -9,17 +9,17 @@ fn pick_key<'a>(kid: &str, jwks: &'a [Jwk]) -> Option<&'a Jwk> { jwks.iter().find(|j| j.kid.as_deref() == Some(kid)) } -pub fn verify_openid_jwt( +pub fn verify_openid_jwt( jwt: &str, issuers: &[&str], jwks: &[Jwk], assert_audience: Aud, - assert_no_replay: Replay, + assert_custom: Custom, ) -> Result, JwtVerifyError> where Claims: DeserializeOwned + JwtClaims, Aud: FnOnce(&Claims) -> Result<(), JwtVerifyError>, - Replay: FnOnce(&Claims) -> Result<(), JwtVerifyError>, + Custom: FnOnce(&Claims) -> Result<(), JwtVerifyError>, { // 1) Read header to get `kid` let header = decode_jwt_header(jwt).map_err(JwtVerifyError::from)?; @@ -67,8 +67,8 @@ where // 6) Manual checks audience assert_audience(c)?; - // 7) Prevent replace attack - assert_no_replay(c)?; + // 7) Assert custom fields such as the nonce for delegation to prevent replay attack + assert_custom(c)?; // 8) Assert expiration let now_ns = now_ns(); diff --git a/src/libs/auth/src/openid/types.rs b/src/libs/auth/src/openid/types.rs index e2a69bb0c9..86606e1a7a 100644 --- a/src/libs/auth/src/openid/types.rs +++ b/src/libs/auth/src/openid/types.rs @@ -10,6 +10,7 @@ pub mod provider { pub enum OpenIdProvider { Google, GitHubAuth, // GitHub user authentication (OAuth) via Juno API proxy + GitHubActions, } #[derive( @@ -20,6 +21,13 @@ pub mod provider { GitHub, } + #[derive( + CandidType, Serialize, Deserialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug, + )] + pub enum OpenIdAutomationProvider { + GitHub, + } + #[derive(CandidType, Serialize, Deserialize, Clone)] pub struct OpenIdCertificate { pub jwks: Jwks, diff --git a/src/libs/auth/src/state/errors.rs b/src/libs/auth/src/state/errors.rs index 77c596c6ae..596b9aa08f 100644 --- a/src/libs/auth/src/state/errors.rs +++ b/src/libs/auth/src/state/errors.rs @@ -2,5 +2,8 @@ pub const JUNO_AUTH_ERROR_INVALID_ORIGIN: &str = "juno.auth.error.invalid_origin"; // No authentication configuration found. pub const JUNO_AUTH_ERROR_NOT_CONFIGURED: &str = "juno.auth.error.not_configured"; +// No automation configuration found. +pub const JUNO_AUTH_ERROR_AUTOMATION_NOT_CONFIGURED: &str = + "juno.auth.error.automation_not_configured"; // Authentication with OpenId disabled. pub const JUNO_AUTH_ERROR_OPENID_DISABLED: &str = "juno.auth.error.openid_disabled"; diff --git a/src/libs/auth/src/state/heap.rs b/src/libs/auth/src/state/heap.rs index 18cba3e7ca..cf26ac3164 100644 --- a/src/libs/auth/src/state/heap.rs +++ b/src/libs/auth/src/state/heap.rs @@ -1,10 +1,10 @@ use crate::openid::types::provider::{OpenIdCertificate, OpenIdProvider}; +use crate::state::types::automation::AutomationConfig; use crate::state::types::config::AuthenticationConfig; use crate::state::types::state::Salt; use crate::state::types::state::{AuthenticationHeapState, OpenIdCachedCertificate, OpenIdState}; use crate::strategies::AuthHeapStrategy; use std::collections::hash_map::Entry; - // --------------------------------------------------------- // Config // --------------------------------------------------------- @@ -23,6 +23,7 @@ fn insert_config_impl(config: &AuthenticationConfig, state: &mut Option { *state = Some(AuthenticationHeapState { config: config.clone(), + automation: None, salt: None, openid: None, }) @@ -31,6 +32,40 @@ fn insert_config_impl(config: &AuthenticationConfig, state: &mut Option Option { + auth_heap.with_auth_state(|authentication| { + authentication + .as_ref() + .and_then(|auth| auth.automation.clone()) + }) +} + +pub fn insert_automation(auth_heap: &impl AuthHeapStrategy, automation: &Option) { + auth_heap + .with_auth_state_mut(|authentication| insert_automation_impl(automation, authentication)) +} + +fn insert_automation_impl( + automation: &Option, + state: &mut Option, +) { + match state { + None => { + *state = Some(AuthenticationHeapState { + config: AuthenticationConfig::default(), + automation: automation.clone(), + salt: None, + openid: None, + }) + } + Some(state) => state.automation = automation.clone(), + } +} + // --------------------------------------------------------- // Salt // --------------------------------------------------------- @@ -48,6 +83,7 @@ fn insert_salt_impl(salt: &Salt, state: &mut Option) { None => { *state = Some(AuthenticationHeapState { config: AuthenticationConfig::default(), + automation: None, salt: Some(*salt), openid: None, }) diff --git a/src/libs/auth/src/state/mod.rs b/src/libs/auth/src/state/mod.rs index 7d5fdf1328..dbe71a87cb 100644 --- a/src/libs/auth/src/state/mod.rs +++ b/src/libs/auth/src/state/mod.rs @@ -9,8 +9,8 @@ mod store; pub mod types; pub use heap::{ - cache_certificate, get_cached_certificate, get_config, get_openid_state, get_salt, insert_salt, - record_fetch_attempt, + cache_certificate, get_automation, get_cached_certificate, get_config, get_openid_state, + get_salt, insert_salt, record_fetch_attempt, }; pub use runtime::*; pub use store::*; diff --git a/src/libs/auth/src/state/store.rs b/src/libs/auth/src/state/store.rs index 7e727e12f9..cdf70a80e5 100644 --- a/src/libs/auth/src/state/store.rs +++ b/src/libs/auth/src/state/store.rs @@ -1,7 +1,11 @@ -use crate::errors::{JUNO_AUTH_ERROR_NOT_CONFIGURED, JUNO_AUTH_ERROR_OPENID_DISABLED}; +use crate::errors::{ + JUNO_AUTH_ERROR_AUTOMATION_NOT_CONFIGURED, JUNO_AUTH_ERROR_NOT_CONFIGURED, + JUNO_AUTH_ERROR_OPENID_DISABLED, +}; use crate::state::assert::assert_set_config; -use crate::state::heap::get_config; use crate::state::heap::insert_config; +use crate::state::heap::{get_automation, get_config}; +use crate::state::types::automation::OpenIdAutomationProviders; use crate::state::types::config::{AuthenticationConfig, OpenIdAuthProviders}; use crate::state::types::interface::SetAuthenticationConfig; use crate::state::{get_salt, insert_salt}; @@ -56,3 +60,15 @@ pub fn get_auth_providers( Ok(openid.providers.clone()) } + +pub fn get_automation_providers( + auth_heap: &impl AuthHeapStrategy, +) -> Result { + let config = + get_automation(auth_heap).ok_or(JUNO_AUTH_ERROR_AUTOMATION_NOT_CONFIGURED.to_string())?; + let openid = config + .openid + .ok_or(JUNO_AUTH_ERROR_OPENID_DISABLED.to_string())?; + + Ok(openid.providers.clone()) +} diff --git a/src/libs/auth/src/state/types.rs b/src/libs/auth/src/state/types.rs index ccae2a31c0..114f2d32f5 100644 --- a/src/libs/auth/src/state/types.rs +++ b/src/libs/auth/src/state/types.rs @@ -1,6 +1,7 @@ pub mod state { use crate::delegation::types::Timestamp; use crate::openid::types::provider::{OpenIdCertificate, OpenIdProvider}; + use crate::state::types::automation::AutomationConfig; use crate::state::types::config::AuthenticationConfig; use candid::CandidType; use serde::{Deserialize, Serialize}; @@ -10,7 +11,11 @@ pub mod state { #[derive(Default, CandidType, Serialize, Deserialize, Clone)] pub struct AuthenticationHeapState { + /// Configuration for user authentication via delegation (Internet Identity, Google, GitHub). + /// Note: Field name kept as "config" for backward compatibility during upgrades. pub config: AuthenticationConfig, + /// Configuration for CI/CD authentication. + pub automation: Option, pub salt: Option, pub openid: Option, } @@ -104,6 +109,64 @@ pub mod config { } } +pub mod automation { + use crate::automation::types::AutomationScope; + use crate::openid::types::provider::OpenIdAutomationProvider; + use candid::{CandidType, Deserialize, Principal}; + use junobuild_shared::types::state::{Timestamp, Version}; + use serde::Serialize; + use std::collections::{BTreeMap, HashMap}; + + #[derive(Default, CandidType, Serialize, Deserialize, Clone)] + pub struct AutomationConfig { + pub openid: Option, + pub version: Option, + pub created_at: Option, + pub updated_at: Option, + } + + #[derive(Default, CandidType, Serialize, Deserialize, Clone)] + pub struct AutomationConfigOpenId { + pub providers: OpenIdAutomationProviders, + pub observatory_id: Option, + } + + pub type OpenIdAutomationProviders = + BTreeMap; + + // Repository identifier for GitHub automation. + // Corresponds to the `repository` claim in GitHub OIDC tokens (e.g., "octo-org/octo-repo"). + // See: https://docs.github.com/en/actions/concepts/security/openid-connect#understanding-the-oidc-token + #[derive(CandidType, Serialize, Deserialize, Clone, Debug, Hash, Eq, PartialEq)] + pub struct RepositoryKey { + // Repository owner (e.g. "octo-org") + pub owner: String, + // Repository name (e.g. "octo-repo") + pub name: String, + } + + pub type OpenIdAutomationRepositories = + HashMap; + + #[derive(Default, CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct OpenIdAutomationProviderConfig { + pub repositories: OpenIdAutomationRepositories, + pub controller: Option, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct OpenIdAutomationRepositoryConfig { + // Optionally restrict to specific branches (e.g. ["main", "develop"]) + pub branches: Option>, + } + + #[derive(CandidType, Serialize, Deserialize, Clone, Debug)] + pub struct OpenIdAutomationProviderControllerConfig { + pub scope: Option, + pub max_time_to_live: Option, + } +} + pub mod interface { use crate::state::types::config::{ AuthenticationConfigInternetIdentity, AuthenticationConfigOpenId, AuthenticationRules, diff --git a/src/libs/collections/src/constants/db.rs b/src/libs/collections/src/constants/db.rs index 79e29b9f00..87296001ae 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,33 @@ 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 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 DEFAULT_DB_COLLECTIONS: [(&str, SetRule); 7] = [ (COLLECTION_USER_KEY, COLLECTION_USER_DEFAULT_RULE), (COLLECTION_LOG_KEY, COLLECTION_LOG_DEFAULT_RULE), ( @@ -91,4 +119,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 c341c742a7..d4a45fda14 100644 --- a/src/libs/satellite/satellite.did +++ b/src/libs/satellite/satellite.did @@ -20,6 +20,13 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateControllerArgs = variant { + OpenId : OpenIdAuthenticateControllerArgs; +}; +type AuthenticateControllerResultResponse = variant { + Ok; + Err : AuthenticationControllerError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; @@ -42,11 +49,16 @@ type AuthenticationConfigOpenId = record { observatory_id : opt principal; providers : vec record { OpenIdDelegationProvider; OpenIdAuthProviderConfig }; }; +type AuthenticationControllerError = variant { + RegisterController : text; + VerifyOpenIdCredentials : VerifyOpenidAutomationCredentialsError; +}; type AuthenticationError = variant { PrepareDelegation : PrepareDelegationError; RegisterUser : text; }; type AuthenticationRules = record { allowed_callers : vec principal }; +type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { batch_id : nat; @@ -220,6 +232,13 @@ type OpenIdAuthProviderDelegationConfig = record { targets : opt vec principal; max_time_to_live : opt nat64; }; +type OpenIdAuthenticateControllerArgs = record { + jwt : text; + metadata : vec record { text; text }; + scope : AutomationScope; + max_time_to_live : opt nat64; + controller_id : principal; +}; type OpenIdDelegationProvider = variant { GitHub; Google }; type OpenIdGetDelegationArgs = record { jwt : text; @@ -374,8 +393,16 @@ type UploadChunk = record { order_id : opt nat; }; type UploadChunkResult = record { chunk_id : nat }; +type VerifyOpenidAutomationCredentialsError = variant { + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; +}; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_controller : (AuthenticateControllerArgs) -> ( + AuthenticateControllerResultResponse, + ); 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..78e854c7a1 --- /dev/null +++ b/src/libs/satellite/src/automation/authenticate.rs @@ -0,0 +1,40 @@ +use crate::auth::strategy_impls::AuthHeap; +use crate::automation::automation; +use crate::automation::register::register_controller; +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, + // TODO: Result> +) -> Result { + let providers = get_automation_providers(&AuthHeap)?; + + // TODO: rate_config of collection? + + let prepared_automation = automation::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, + ))); + } + + register_controller(&automation); + + Ok(()) + } + Err(err) => Err(AuthenticationAutomationError::PrepareAutomation(err)), + }; + + Ok(result) +} diff --git a/src/libs/satellite/src/automation/automation.rs b/src/libs/satellite/src/automation/automation.rs new file mode 100644 index 0000000000..699955de1f --- /dev/null +++ b/src/libs/satellite/src/automation/automation.rs @@ -0,0 +1,37 @@ +use crate::auth::strategy_impls::AuthHeap; +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, providers, &AuthHeap, + ) + .await + { + Ok(value) => value, + Err(err) => return Err(PrepareAutomationError::from(err)), + }; + + let result = automation::openid_prepare_automation(&args.controller_id, &provider, &AuthHeap); + + result.map(|prepared_automation| (prepared_automation, provider, credential)) +} diff --git a/src/libs/satellite/src/automation/mod.rs b/src/libs/satellite/src/automation/mod.rs new file mode 100644 index 0000000000..b52d24c0a2 --- /dev/null +++ b/src/libs/satellite/src/automation/mod.rs @@ -0,0 +1,9 @@ +pub mod authenticate; +mod automation; +mod register; +mod token; +pub mod types; +mod workflow; + +pub use token::assert::*; +pub use workflow::assert::*; diff --git a/src/libs/satellite/src/automation/register.rs b/src/libs/satellite/src/automation/register.rs new file mode 100644 index 0000000000..b0aea217a5 --- /dev/null +++ b/src/libs/satellite/src/automation/register.rs @@ -0,0 +1,19 @@ +use crate::controllers::store::set_controllers; +use junobuild_auth::automation::types::PreparedAutomation; +use junobuild_shared::types::interface::SetController; +use junobuild_shared::types::state::ControllerId; +use std::collections::HashMap; + +pub fn register_controller(prepared_automation: &PreparedAutomation) { + let controllers: [ControllerId; 1] = [prepared_automation.controller.id.clone()]; + + // TODO: jti in metadata? to know the source? + let controller: SetController = SetController { + scope: prepared_automation.controller.scope.clone().into(), + metadata: HashMap::default(), // TODO args.metadata.clone(), + expires_at: Some(prepared_automation.controller.expires_at), + // TODO: type or metadata + }; + + set_controllers(&controllers, &controller); +} 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..584a3f6c2b --- /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: &String) -> Self { + Self { + provider: provider.clone(), + jti: jti.clone(), + } + } + + pub fn to_key(&self) -> String { + format!("{}#{}", self.provider.to_string(), 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..e164585d88 --- /dev/null +++ b/src/libs/satellite/src/automation/token/services.rs @@ -0,0 +1,69 @@ +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 automation_token_data: AutomationTokenData = AutomationTokenData { + controller_id: DocDataPrincipal { + value: prepared_automation.controller.id, + }, + }; + + 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..ef33018325 --- /dev/null +++ b/src/libs/satellite/src/automation/types.rs @@ -0,0 +1,18 @@ +use candid::{CandidType, Deserialize}; +use junobuild_auth::automation::types::{OpenIdPrepareAutomationArgs, PrepareAutomationError}; +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<(), AuthenticationAutomationError>; 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..f84c41c980 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/impls.rs @@ -0,0 +1,44 @@ +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: &String, + run_id: &String, + ) -> Self { + Self { + provider: provider.clone(), + repository: repository.clone(), + run_id: run_id.clone(), + } + } + + pub fn to_key(&self) -> String { + format!( + "{}#{}#{}", + self.provider.to_string(), + 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..528a444196 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/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/workflow/services.rs b/src/libs/satellite/src/automation/workflow/services.rs new file mode 100644 index 0000000000..2a1f3967d3 --- /dev/null +++ b/src/libs/satellite/src/automation/workflow/services.rs @@ -0,0 +1,74 @@ +use crate::automation::workflow::types::state::{AutomationWorkflowData, AutomationWorkflowKey}; +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_WORKFLOW_ERROR_MISSING_REPOSITORY, + JUNO_AUTOMATION_WORKFLOW_ERROR_MISSING_RUN_ID, +}; +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 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).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/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 555c6c6a0b..ef2c479a64 100644 --- a/src/libs/satellite/src/lib.rs +++ b/src/libs/satellite/src/lib.rs @@ -3,6 +3,7 @@ mod api; mod assets; mod auth; +mod automation; mod certification; mod controllers; mod db; @@ -24,10 +25,11 @@ use crate::guards::{ caller_is_admin_controller, caller_is_controller, caller_is_controller_with_write, }; 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::config::AuthenticationConfig; use junobuild_auth::state::types::interface::SetAuthenticationConfig; @@ -176,6 +178,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 // --------------------------------------------------------- @@ -540,10 +550,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_config, get_db_config, get_delegation, get_doc, get_many_assets, get_many_docs, get_proposal, get_storage_config, 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 cfcc94b79e..3e5f975faa 100644 --- a/src/libs/satellite/src/types.rs +++ b/src/libs/satellite/src/types.rs @@ -56,6 +56,7 @@ pub mod state { } pub mod interface { + use crate::automation::types::AuthenticationAutomationError; use crate::db::types::config::DbConfig; use crate::Doc; use candid::CandidType; @@ -118,6 +119,12 @@ pub mod interface { Ok(SignedDelegation), Err(GetDelegationError), } + + #[derive(CandidType, Serialize, Deserialize)] + pub enum AuthenticateAutomationResultResponse { + Ok(()), + Err(AuthenticationAutomationError), + } } pub mod store { diff --git a/src/observatory/observatory.did b/src/observatory/observatory.did index 000cc450e9..f2058620c1 100644 --- a/src/observatory/observatory.did +++ b/src/observatory/observatory.did @@ -70,7 +70,7 @@ type OpenIdCertificate = record { created_at : nat64; version : opt nat64; }; -type OpenIdProvider = variant { Google; GitHubAuth }; +type OpenIdProvider = variant { GitHubActions; Google; GitHubAuth }; type RateConfig = record { max_tokens : nat64; time_per_token_ns : nat64 }; type RateKind = variant { OpenIdCertificateRequests }; type Segment = record { diff --git a/src/observatory/src/openid/scheduler.rs b/src/observatory/src/openid/scheduler.rs index 8cb03af9bc..06f63c206f 100644 --- a/src/observatory/src/openid/scheduler.rs +++ b/src/observatory/src/openid/scheduler.rs @@ -9,10 +9,14 @@ use std::time::Duration; pub fn defer_restart_monitoring() { // Early spare one timer if no scheduler is enabled. - let enabled_count = [OpenIdProvider::Google, OpenIdProvider::GitHubAuth] - .into_iter() - .filter(is_scheduler_enabled) - .count(); + let enabled_count = [ + OpenIdProvider::Google, + OpenIdProvider::GitHubAuth, + OpenIdProvider::GitHubActions, + ] + .into_iter() + .filter(|provider| is_scheduler_enabled(provider)) + .count(); if enabled_count == 0 { return; @@ -24,7 +28,11 @@ pub fn defer_restart_monitoring() { } async fn restart_monitoring() { - for provider in [OpenIdProvider::Google, OpenIdProvider::GitHubAuth] { + for provider in [ + OpenIdProvider::Google, + OpenIdProvider::GitHubAuth, + OpenIdProvider::GitHubActions, + ] { schedule_certificate_update(provider, None); } } diff --git a/src/satellite/satellite.did b/src/satellite/satellite.did index a7b4c29b57..fa0638de7b 100644 --- a/src/satellite/satellite.did +++ b/src/satellite/satellite.did @@ -22,6 +22,13 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateControllerArgs = variant { + OpenId : OpenIdAuthenticateControllerArgs; +}; +type AuthenticateControllerResultResponse = variant { + Ok; + Err : AuthenticationControllerError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; @@ -44,11 +51,16 @@ type AuthenticationConfigOpenId = record { observatory_id : opt principal; providers : vec record { OpenIdDelegationProvider; OpenIdAuthProviderConfig }; }; +type AuthenticationControllerError = variant { + RegisterController : text; + VerifyOpenIdCredentials : VerifyOpenidAutomationCredentialsError; +}; type AuthenticationError = variant { PrepareDelegation : PrepareDelegationError; RegisterUser : text; }; type AuthenticationRules = record { allowed_callers : vec principal }; +type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { batch_id : nat; @@ -222,6 +234,13 @@ type OpenIdAuthProviderDelegationConfig = record { targets : opt vec principal; max_time_to_live : opt nat64; }; +type OpenIdAuthenticateControllerArgs = record { + jwt : text; + metadata : vec record { text; text }; + scope : AutomationScope; + max_time_to_live : opt nat64; + controller_id : principal; +}; type OpenIdDelegationProvider = variant { GitHub; Google }; type OpenIdGetDelegationArgs = record { jwt : text; @@ -376,8 +395,16 @@ type UploadChunk = record { order_id : opt nat; }; type UploadChunkResult = record { chunk_id : nat }; +type VerifyOpenidAutomationCredentialsError = variant { + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; +}; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_controller : (AuthenticateControllerArgs) -> ( + AuthenticateControllerResultResponse, + ); 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 26f6a05509..1e0c420adb 100644 --- a/src/sputnik/sputnik.did +++ b/src/sputnik/sputnik.did @@ -22,6 +22,13 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateControllerArgs = variant { + OpenId : OpenIdAuthenticateControllerArgs; +}; +type AuthenticateControllerResultResponse = variant { + Ok; + Err : AuthenticationControllerError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; @@ -44,11 +51,16 @@ type AuthenticationConfigOpenId = record { observatory_id : opt principal; providers : vec record { OpenIdDelegationProvider; OpenIdAuthProviderConfig }; }; +type AuthenticationControllerError = variant { + RegisterController : text; + VerifyOpenIdCredentials : VerifyOpenidAutomationCredentialsError; +}; type AuthenticationError = variant { PrepareDelegation : PrepareDelegationError; RegisterUser : text; }; type AuthenticationRules = record { allowed_callers : vec principal }; +type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { batch_id : nat; @@ -222,6 +234,13 @@ type OpenIdAuthProviderDelegationConfig = record { targets : opt vec principal; max_time_to_live : opt nat64; }; +type OpenIdAuthenticateControllerArgs = record { + jwt : text; + metadata : vec record { text; text }; + scope : AutomationScope; + max_time_to_live : opt nat64; + controller_id : principal; +}; type OpenIdDelegationProvider = variant { GitHub; Google }; type OpenIdGetDelegationArgs = record { jwt : text; @@ -376,8 +395,16 @@ type UploadChunk = record { order_id : opt nat; }; type UploadChunkResult = record { chunk_id : nat }; +type VerifyOpenidAutomationCredentialsError = variant { + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; +}; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_controller : (AuthenticateControllerArgs) -> ( + AuthenticateControllerResultResponse, + ); 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 5f232ebcba..0248c33ef6 100644 --- a/src/tests/declarations/test_satellite/test_satellite.did.d.ts +++ b/src/tests/declarations/test_satellite/test_satellite.did.d.ts @@ -34,6 +34,12 @@ export interface AssetNoContent { export interface AssetsUpgradeOptions { clear_existing_assets: [] | [boolean]; } +export type AuthenticateControllerArgs = { + OpenId: OpenIdAuthenticateControllerArgs; +}; +export type AuthenticateControllerResultResponse = + | { Ok: null } + | { Err: AuthenticationControllerError }; export type AuthenticateResultResponse = { Ok: Authentication } | { Err: AuthenticationError }; export interface Authentication { doc: Doc; @@ -56,6 +62,9 @@ export interface AuthenticationConfigOpenId { observatory_id: [] | [Principal]; providers: Array<[OpenIdDelegationProvider, OpenIdAuthProviderConfig]>; } +export type AuthenticationControllerError = + | { RegisterController: string } + | { VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError }; export type AuthenticationError = | { PrepareDelegation: PrepareDelegationError; @@ -64,6 +73,7 @@ export type AuthenticationError = export interface AuthenticationRules { allowed_callers: Array; } +export type AutomationScope = { Write: null } | { Submit: null }; export type CollectionType = { Db: null } | { Storage: null }; export interface CommitBatch { batch_id: bigint; @@ -269,6 +279,13 @@ export interface OpenIdAuthProviderDelegationConfig { targets: [] | [Array]; max_time_to_live: [] | [bigint]; } +export interface OpenIdAuthenticateControllerArgs { + jwt: string; + metadata: Array<[string, string]>; + scope: AutomationScope; + max_time_to_live: [] | [bigint]; + controller_id: Principal; +} export type OpenIdDelegationProvider = { GitHub: null } | { Google: null }; export interface OpenIdGetDelegationArgs { jwt: string; @@ -442,8 +459,18 @@ export interface UploadChunk { export interface UploadChunkResult { chunk_id: bigint; } +export type VerifyOpenidAutomationCredentialsError = + | { + GetCachedJwks: null; + } + | { JwtVerify: JwtVerifyError } + | { GetOrFetchJwks: GetOrRefreshJwksError }; export interface _SERVICE { authenticate: ActorMethod<[AuthenticationArgs], AuthenticateResultResponse>; + authenticate_controller: ActorMethod< + [AuthenticateControllerArgs], + AuthenticateControllerResultResponse + >; 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 bf6976362b..4827d8f258 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 @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -453,6 +480,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 794fe291c0..ccd764a437 100644 --- a/src/tests/declarations/test_satellite/test_satellite.factory.did.js +++ b/src/tests/declarations/test_satellite/test_satellite.factory.did.js @@ -75,6 +75,33 @@ export const idlFactory = ({ IDL }) => { Ok: Authentication, Err: AuthenticationError }); + const AutomationScope = IDL.Variant({ + Write: IDL.Null, + Submit: IDL.Null + }); + const OpenIdAuthenticateControllerArgs = IDL.Record({ + jwt: IDL.Text, + metadata: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), + scope: AutomationScope, + max_time_to_live: IDL.Opt(IDL.Nat64), + controller_id: IDL.Principal + }); + const AuthenticateControllerArgs = IDL.Variant({ + OpenId: OpenIdAuthenticateControllerArgs + }); + const VerifyOpenidAutomationCredentialsError = IDL.Variant({ + GetCachedJwks: IDL.Null, + JwtVerify: JwtVerifyError, + GetOrFetchJwks: GetOrRefreshJwksError + }); + const AuthenticationControllerError = IDL.Variant({ + RegisterController: IDL.Text, + VerifyOpenIdCredentials: VerifyOpenidAutomationCredentialsError + }); + const AuthenticateControllerResultResponse = IDL.Variant({ + Ok: IDL.Null, + Err: AuthenticationControllerError + }); const CommitBatch = IDL.Record({ batch_id: IDL.Nat, headers: IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), @@ -453,6 +480,11 @@ export const idlFactory = ({ IDL }) => { return IDL.Service({ authenticate: IDL.Func([AuthenticationArgs], [AuthenticateResultResponse], []), + authenticate_controller: IDL.Func( + [AuthenticateControllerArgs], + [AuthenticateControllerResultResponse], + [] + ), 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 1c04d35bdf..a7874deec0 100644 --- a/src/tests/fixtures/test_satellite/test_satellite.did +++ b/src/tests/fixtures/test_satellite/test_satellite.did @@ -22,6 +22,13 @@ type AssetNoContent = record { version : opt nat64; }; type AssetsUpgradeOptions = record { clear_existing_assets : opt bool }; +type AuthenticateControllerArgs = variant { + OpenId : OpenIdAuthenticateControllerArgs; +}; +type AuthenticateControllerResultResponse = variant { + Ok; + Err : AuthenticationControllerError; +}; type AuthenticateResultResponse = variant { Ok : Authentication; Err : AuthenticationError; @@ -44,11 +51,16 @@ type AuthenticationConfigOpenId = record { observatory_id : opt principal; providers : vec record { OpenIdDelegationProvider; OpenIdAuthProviderConfig }; }; +type AuthenticationControllerError = variant { + RegisterController : text; + VerifyOpenIdCredentials : VerifyOpenidAutomationCredentialsError; +}; type AuthenticationError = variant { PrepareDelegation : PrepareDelegationError; RegisterUser : text; }; type AuthenticationRules = record { allowed_callers : vec principal }; +type AutomationScope = variant { Write; Submit }; type CollectionType = variant { Db; Storage }; type CommitBatch = record { batch_id : nat; @@ -222,6 +234,13 @@ type OpenIdAuthProviderDelegationConfig = record { targets : opt vec principal; max_time_to_live : opt nat64; }; +type OpenIdAuthenticateControllerArgs = record { + jwt : text; + metadata : vec record { text; text }; + scope : AutomationScope; + max_time_to_live : opt nat64; + controller_id : principal; +}; type OpenIdDelegationProvider = variant { GitHub; Google }; type OpenIdGetDelegationArgs = record { jwt : text; @@ -376,8 +395,16 @@ type UploadChunk = record { order_id : opt nat; }; type UploadChunkResult = record { chunk_id : nat }; +type VerifyOpenidAutomationCredentialsError = variant { + GetCachedJwks; + JwtVerify : JwtVerifyError; + GetOrFetchJwks : GetOrRefreshJwksError; +}; service : (InitSatelliteArgs) -> { authenticate : (AuthenticationArgs) -> (AuthenticateResultResponse); + authenticate_controller : (AuthenticateControllerArgs) -> ( + AuthenticateControllerResultResponse, + ); commit_asset_upload : (CommitBatch) -> (); commit_proposal : (CommitProposal) -> (null); commit_proposal_asset_upload : (CommitBatch) -> ();