feat: OAuth 2.0 authorization code flow with PKCE spec
This commit is contained in:
parent
1d536136b8
commit
14cd892e8a
1 changed files with 452 additions and 0 deletions
452
examples/OAuth2AuthorizationCode.speck
Normal file
452
examples/OAuth2AuthorizationCode.speck
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
speck OAuth2AuthorizationCode {
|
||||
// OAuth 2.0 Authorization Code Flow with PKCE
|
||||
// Implements RFC 6749 Section 4.1 (Authorization Code Grant) with RFC 7636 (PKCE)
|
||||
// Key security properties: CSRF protection, code reuse prevention, replay resistance, PKCE integrity
|
||||
|
||||
// === Types ===
|
||||
|
||||
type ClientId = String
|
||||
type RedirectUri = String
|
||||
type State = String
|
||||
type CodeChallenge = String
|
||||
type CodeVerifier = String
|
||||
type AuthorizationCode = String
|
||||
type AccessToken = String
|
||||
type RefreshToken = String
|
||||
type Scope = String
|
||||
type TokenType = String
|
||||
type ClientSecret = String
|
||||
type UserId = String
|
||||
type SessionId = String
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: AccessToken,
|
||||
token_type: TokenType,
|
||||
expires_in: Nat,
|
||||
refresh_token: RefreshToken | null,
|
||||
scope: Scope
|
||||
}
|
||||
|
||||
type AuthorizationRequest = {
|
||||
client_id: ClientId,
|
||||
redirect_uri: RedirectUri,
|
||||
response_type: String, // "code"
|
||||
state: State,
|
||||
code_challenge: CodeChallenge,
|
||||
code_challenge_method: String, // "S256" or "plain"
|
||||
scope: Scope
|
||||
}
|
||||
|
||||
type TokenRequest = {
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
code: AuthorizationCode,
|
||||
redirect_uri: RedirectUri,
|
||||
code_verifier: CodeVerifier
|
||||
}
|
||||
|
||||
type RefreshRequest = {
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
refresh_token: RefreshToken,
|
||||
scope: Scope | null
|
||||
}
|
||||
|
||||
// === State ===
|
||||
|
||||
state {
|
||||
// Registered clients
|
||||
clients: Map(ClientId, { redirect_uris: Set(RedirectUri), client_secret: ClientSecret })
|
||||
|
||||
// Authorization codes in flight
|
||||
authorizationCodes: Map(AuthorizationCode, {
|
||||
client_id: ClientId,
|
||||
redirect_uri: RedirectUri,
|
||||
state: State,
|
||||
code_challenge: CodeChallenge,
|
||||
code_challenge_method: String,
|
||||
scope: Scope,
|
||||
user_id: UserId,
|
||||
session_id: SessionId,
|
||||
created_at: Nat,
|
||||
used: Bool
|
||||
})
|
||||
|
||||
// Access tokens issued
|
||||
accessTokens: Map(AccessToken, {
|
||||
client_id: ClientId,
|
||||
user_id: UserId,
|
||||
scope: Scope,
|
||||
expires_at: Nat,
|
||||
session_id: SessionId,
|
||||
refresh_token: RefreshToken
|
||||
})
|
||||
|
||||
// Refresh tokens
|
||||
refreshTokens: Map(RefreshToken, {
|
||||
client_id: ClientId,
|
||||
user_id: UserId,
|
||||
scope: Scope,
|
||||
access_token: AccessToken,
|
||||
created_at: Nat
|
||||
})
|
||||
|
||||
// User sessions
|
||||
sessions: Map(SessionId, { user_id: UserId })
|
||||
|
||||
// Nonce for state to prevent CSRF
|
||||
stateNonces: Map(State, { client_id: ClientId, redirect_uri: RedirectUri })
|
||||
|
||||
// PKCE challenge nonces
|
||||
pkceChallenges: Map(CodeChallenge, { code_verifier_hash: String, method: String })
|
||||
|
||||
// Clock (abstracted as monotonically increasing)
|
||||
currentTime: Nat
|
||||
}
|
||||
|
||||
// === Initialization ===
|
||||
|
||||
init {
|
||||
clients == emptyMap
|
||||
authorizationCodes == emptyMap
|
||||
accessTokens == emptyMap
|
||||
refreshTokens == emptyMap
|
||||
sessions == emptyMap
|
||||
stateNonces == emptyMap
|
||||
pkceChallenges == emptyMap
|
||||
currentTime == 0
|
||||
}
|
||||
|
||||
// === Invariants ===
|
||||
|
||||
// Invariant 1: No token leakage - tokens are only accessible to authorized parties
|
||||
invariant NoTokenLeakage {
|
||||
// Access tokens are bound to specific clients and users
|
||||
forall token in accessTokens.keys:
|
||||
let t := accessTokens[token]
|
||||
t.client_id in clients.keys and
|
||||
exists s in sessions.keys: sessions[s].user_id == t.user_id
|
||||
|
||||
// Refresh tokens are only accessible to the client that requested them
|
||||
forall rt in refreshTokens.keys:
|
||||
let r := refreshTokens[rt]
|
||||
r.client_id in clients.keys
|
||||
|
||||
// Authorization codes are only sent to the registered redirect URI
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
c.client_id in clients.keys and
|
||||
c.redirect_uri in clients[c.client_id].redirect_uris
|
||||
}
|
||||
|
||||
// Invariant 2: CSRF protection - state parameter matches between authorize and callback
|
||||
invariant StateCSRFProtection {
|
||||
// Every state must have been registered by the client
|
||||
forall state in stateNonces.keys:
|
||||
let n := stateNonces[state]
|
||||
n.client_id in clients.keys and
|
||||
n.redirect_uri in clients[n.client_id].redirect_uris
|
||||
|
||||
// State can only be used by the client that created it
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
c.state in stateNonces.keys
|
||||
}
|
||||
|
||||
// Invariant 3: Authorization codes are single-use (cannot be reused)
|
||||
invariant AuthorizationCodeSingleUse {
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
c.used == false implies
|
||||
not exists action in next:
|
||||
action.name == "ExchangeCode" and action.params.code == code
|
||||
}
|
||||
|
||||
// Invariant 4: Tokens have bounded validity (replay attack resistance)
|
||||
invariant TokenValidityBounded {
|
||||
// Access tokens expire after a reasonable time
|
||||
forall token in accessTokens.keys:
|
||||
let t := accessTokens[token]
|
||||
t.expires_at > currentTime
|
||||
|
||||
// Refresh tokens expire after a longer period
|
||||
forall rt in refreshTokens.keys:
|
||||
let r := refreshTokens[rt]
|
||||
// (Simplified: refresh tokens should have expiration, but we track creation time)
|
||||
currentTime - r.created_at <= 7776000 // 90 days max
|
||||
}
|
||||
|
||||
// Invariant 5: PKCE prevents authorization code interception
|
||||
invariant PKCECodeInterceptionPrevention {
|
||||
// Code verifier must hash to the code challenge
|
||||
forall challenge in pkceChallenges.keys:
|
||||
let c := pkceChallenges[challenge]
|
||||
// In practice, this would use actual hashing, but we verify the relationship
|
||||
exists verifier in authorizationCodes.keys:
|
||||
let a := authorizationCodes[verifier]
|
||||
a.code_challenge == challenge and
|
||||
a.code_challenge_method == c.method
|
||||
|
||||
// Code verifier is only used once with the matching challenge
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
c.used == false implies
|
||||
pkceChallenges[c.code_challenge].code_verifier_hash == hash(c.code_verifier, c.code_challenge_method)
|
||||
}
|
||||
|
||||
// Invariant 6: Redirect URI must match between authorize and token exchange
|
||||
invariant RedirectUriConsistency {
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
// The redirect URI used in token exchange must match what was in authorize
|
||||
true // This is enforced in the ExchangeCode action
|
||||
}
|
||||
|
||||
// Invariant 7: Client authentication required for token exchange
|
||||
invariant ClientAuthenticationRequired {
|
||||
forall code in authorizationCodes.keys:
|
||||
let c := authorizationCodes[code]
|
||||
c.client_id in clients.keys implies
|
||||
exists r in refreshTokens.keys:
|
||||
refreshTokens[r].client_id == c.client_id
|
||||
}
|
||||
|
||||
// === Actions ===
|
||||
|
||||
// Action 1: Authorize - Client requests authorization code
|
||||
action Authorize(client: ClientId, redirectUri: RedirectUri, scope: Scope, userId: UserId, sessionId: SessionId) {
|
||||
require client in clients.keys
|
||||
require redirectUri in clients.client.redirect_uris
|
||||
require sessionId in sessions.keys
|
||||
require sessions[sessionId].user_id == userId
|
||||
|
||||
// Generate unique state (CSRF protection)
|
||||
let state := "state_" + hash(client + redirectUri + currentTime)
|
||||
|
||||
// Generate PKCE code challenge
|
||||
let codeVerifier := "verifier_" + hash(client + userId + currentTime)
|
||||
let codeChallenge := hash(codeVerifier, "S256")
|
||||
|
||||
// Register state nonce
|
||||
stateNonces[state] := { client_id: client, redirect_uri: redirectUri }
|
||||
|
||||
// Register PKCE challenge
|
||||
pkceChallenges[codeChallenge] := { code_verifier_hash: hash(codeVerifier, "S256"), method: "S256" }
|
||||
|
||||
// Create authorization code
|
||||
let authCode := "code_" + hash(client + userId + currentTime)
|
||||
authorizationCodes[authCode] := {
|
||||
client_id: client,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: scope,
|
||||
user_id: userId,
|
||||
session_id: sessionId,
|
||||
created_at: currentTime,
|
||||
used: false
|
||||
}
|
||||
|
||||
emit AuthorizationRequested {
|
||||
client_id: client,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
scope: scope,
|
||||
user_id: userId,
|
||||
session_id: sessionId,
|
||||
authorization_code: authCode
|
||||
}
|
||||
}
|
||||
|
||||
// Action 2: Approve - User grants consent (simulated: user confirms action)
|
||||
action ApproveAuthorization(code: AuthorizationCode, userId: UserId) {
|
||||
require code in authorizationCodes.keys
|
||||
let c := authorizationCodes[code]
|
||||
require c.user_id == userId
|
||||
require c.used == false
|
||||
|
||||
// Mark as approved (ready for exchange)
|
||||
emit CodeIssued {
|
||||
authorization_code: code,
|
||||
client_id: c.client_id,
|
||||
redirect_uri: c.redirect_uri,
|
||||
state: c.state,
|
||||
scope: c.scope,
|
||||
user_id: userId
|
||||
}
|
||||
}
|
||||
|
||||
// Action 3: ExchangeCode - Client exchanges code for tokens
|
||||
action ExchangeCode(req: TokenRequest) {
|
||||
require req.client_id in clients.keys
|
||||
|
||||
// Verify authorization code exists and is not used
|
||||
require req.code in authorizationCodes.keys
|
||||
let c := authorizationCodes[req.code]
|
||||
require c.used == false
|
||||
require c.client_id == req.client_id
|
||||
require c.redirect_uri == req.redirect_uri
|
||||
|
||||
// Verify PKCE: code_verifier must hash to code_challenge
|
||||
let expectedChallenge := hash(req.code_verifier, c.code_challenge_method)
|
||||
require expectedChallenge == c.code_challenge
|
||||
|
||||
// Mark code as used (single-use enforcement)
|
||||
authorizationCodes[req.code].used := true
|
||||
|
||||
// Remove PKCE challenge (one-time use)
|
||||
pkceChallenges[c.code_challenge] := null
|
||||
|
||||
// Remove state nonce (one-time use)
|
||||
stateNonces[c.state] := null
|
||||
|
||||
// Issue access token
|
||||
let accessToken := "access_" + hash(req.client_id + c.user_id + currentTime)
|
||||
let refreshToken := "refresh_" + hash(req.client_id + c.user_id + currentTime)
|
||||
let expiresAt := currentTime + 3600 // 1 hour
|
||||
|
||||
accessTokens[accessToken] := {
|
||||
client_id: req.client_id,
|
||||
user_id: c.user_id,
|
||||
scope: c.scope,
|
||||
expires_at: expiresAt,
|
||||
session_id: c.session_id,
|
||||
refresh_token: refreshToken
|
||||
}
|
||||
|
||||
refreshTokens[refreshToken] := {
|
||||
client_id: req.client_id,
|
||||
user_id: c.user_id,
|
||||
scope: c.scope,
|
||||
access_token: accessToken,
|
||||
created_at: currentTime
|
||||
}
|
||||
|
||||
emit TokenIssued {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: refreshToken,
|
||||
scope: c.scope,
|
||||
client_id: req.client_id,
|
||||
user_id: c.user_id
|
||||
}
|
||||
}
|
||||
|
||||
// Action 4: RefreshToken - Client refreshes expired access token
|
||||
action RefreshToken(req: RefreshRequest) {
|
||||
require req.client_id in clients.keys
|
||||
|
||||
// Verify refresh token exists
|
||||
require req.refresh_token in refreshTokens.keys
|
||||
let r := refreshTokens[req.refresh_token]
|
||||
require r.client_id == req.client_id
|
||||
|
||||
// Mark old access token as revoked (implicit)
|
||||
accessTokens[r.access_token] := null
|
||||
|
||||
// Issue new tokens
|
||||
let newAccessToken := "access_" + hash(req.client_id + r.user_id + currentTime)
|
||||
let newRefreshToken := "refresh_" + hash(req.client_id + r.user_id + currentTime)
|
||||
let expiresAt := currentTime + 3600
|
||||
|
||||
accessTokens[newAccessToken] := {
|
||||
client_id: req.client_id,
|
||||
user_id: r.user_id,
|
||||
scope: req.scope.or(r.scope),
|
||||
expires_at: expiresAt,
|
||||
session_id: r.access_token.session_id,
|
||||
refresh_token: newRefreshToken
|
||||
}
|
||||
|
||||
refreshTokens[newRefreshToken] := {
|
||||
client_id: req.client_id,
|
||||
user_id: r.user_id,
|
||||
scope: req.scope.or(r.scope),
|
||||
access_token: newAccessToken,
|
||||
created_at: currentTime
|
||||
}
|
||||
|
||||
// Remove old refresh token
|
||||
refreshTokens[req.refresh_token] := null
|
||||
|
||||
emit TokenRefreshed {
|
||||
access_token: newAccessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: newRefreshToken,
|
||||
scope: req.scope.or(r.scope),
|
||||
client_id: req.client_id,
|
||||
user_id: r.user_id
|
||||
}
|
||||
}
|
||||
|
||||
// Action 5: RevokeToken - Client revokes a token
|
||||
action RevokeToken(client: ClientId, token: String, tokenType: String) {
|
||||
require client in clients.keys
|
||||
|
||||
if tokenType == "access" {
|
||||
require token in accessTokens.keys
|
||||
let t := accessTokens[token]
|
||||
require t.client_id == client
|
||||
|
||||
accessTokens[token] := null
|
||||
|
||||
// Also revoke associated refresh token
|
||||
refreshTokens[t.refresh_token] := null
|
||||
} else if tokenType == "refresh" {
|
||||
require token in refreshTokens.keys
|
||||
let r := refreshTokens[token]
|
||||
require r.client_id == client
|
||||
|
||||
refreshTokens[token] := null
|
||||
accessTokens[r.access_token] := null
|
||||
}
|
||||
|
||||
emit TokenRevoked {
|
||||
token: token,
|
||||
token_type: tokenType,
|
||||
client_id: client
|
||||
}
|
||||
}
|
||||
|
||||
// Action 6: PKCE Verification Step (explicit verification for documentation)
|
||||
action VerifyPKCE(code: AuthorizationCode, verifier: CodeVerifier) {
|
||||
require code in authorizationCodes.keys
|
||||
let c := authorizationCodes[code]
|
||||
require c.used == false
|
||||
|
||||
// Verify the code verifier hashes to the code challenge
|
||||
let expectedChallenge := hash(verifier, c.code_challenge_method)
|
||||
require expectedChallenge == c.code_challenge
|
||||
|
||||
// This action documents the PKCE verification step
|
||||
emit PKCEVerified {
|
||||
authorization_code: code,
|
||||
code_challenge: c.code_challenge,
|
||||
code_challenge_method: c.code_challenge_method,
|
||||
verifier_hash: hash(verifier, c.code_challenge_method)
|
||||
}
|
||||
}
|
||||
|
||||
// === Next Actions ===
|
||||
|
||||
next:
|
||||
Authorize
|
||||
| ApproveAuthorization
|
||||
| ExchangeCode
|
||||
| RefreshToken
|
||||
| RevokeToken
|
||||
| VerifyPKCE
|
||||
|
||||
// === Verification ===
|
||||
|
||||
verify: Always(NoTokenLeakage)
|
||||
verify: Always(StateCSRFProtection)
|
||||
verify: Always(AuthorizationCodeSingleUse)
|
||||
verify: Always(TokenValidityBounded)
|
||||
verify: Always(PKCECodeInterceptionPrevention)
|
||||
verify: Always(RedirectUriConsistency)
|
||||
verify: Always(ClientAuthenticationRequired)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue