Skip to content

Commit

Permalink
refactor: use scope for read only api keys
Browse files Browse the repository at this point in the history
  • Loading branch information
bodymindarts committed Nov 15, 2023
1 parent d78b26d commit 2453428
Show file tree
Hide file tree
Showing 10 changed files with 59 additions and 54 deletions.
1 change: 1 addition & 0 deletions core/api-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pub mod cli;
mod entity;
pub mod graphql;
pub mod identity;
pub mod scope;
pub mod server;
14 changes: 14 additions & 0 deletions core/api-keys/src/scope.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
pub const READ_SCOPE: &str = "read";
pub const WRITE_SCOPE: &str = "write";

pub fn read_only_scope() -> String {
format!("{READ_SCOPE}")
}

pub fn read_write_scope() -> String {
format!("{READ_SCOPE} {WRITE_SCOPE}")
}

pub fn is_read_only(scope: &String) -> bool {
!(scope.as_str().split(" ").any(|s| s == WRITE_SCOPE) || scope.is_empty())
}
14 changes: 10 additions & 4 deletions core/api-keys/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub struct JwtClaims {
sub: String,
exp: u64,
#[serde(default)]
read_only: String,
scope: String,
}

pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyhow::Result<()> {
Expand Down Expand Up @@ -59,7 +59,7 @@ pub async fn run_server(config: ServerConfig, api_keys_app: ApiKeysApp) -> anyho
#[derive(Debug, Serialize)]
struct CheckResponse {
sub: String,
read_only: bool,
scope: String,
}

async fn check_handler(
Expand All @@ -68,7 +68,12 @@ async fn check_handler(
) -> Result<Json<CheckResponse>, ApplicationError> {
let key = headers.get(header).ok_or(ApplicationError::MissingApiKey)?;
let (sub, read_only) = app.lookup_authenticated_subject(key.to_str()?).await?;
Ok(Json(CheckResponse { sub, read_only }))
let scope = if read_only {
crate::scope::read_only_scope()
} else {
crate::scope::read_write_scope()
};
Ok(Json(CheckResponse { sub, scope }))
}

pub async fn graphql_handler(
Expand All @@ -77,10 +82,11 @@ pub async fn graphql_handler(
req: GraphQLRequest,
) -> GraphQLResponse {
let req = req.into_inner();
let read_only = crate::scope::is_read_only(&jwt_claims.scope);
schema
.execute(req.data(graphql::AuthSubject {
id: jwt_claims.sub,
read_only: jwt_claims.read_only.eq("true"),
read_only,
}))
.await
.into()
Expand Down
2 changes: 0 additions & 2 deletions core/api/dev/apollo-federation/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,13 @@ type ApiKey
expired: Boolean!
lastUsedAt: Timestamp
expiresAt: Timestamp!
readOnly: Boolean!
}

input ApiKeyCreateInput
@join__type(graph: API_KEYS)
{
name: String!
expireInDays: Int
readOnly: Boolean! = false
}

type ApiKeyCreatePayload
Expand Down
5 changes: 2 additions & 3 deletions core/api/src/domain/authorization/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export const ScopesOauth2 = {
TransactionsRead: "transactions:read",
Offline: "offline",
PaymentsSend: "payments:send",
Read: "read",
Write: "write",
} as const
export default ScopesOauth2
10 changes: 1 addition & 9 deletions core/api/src/servers/graphql-main-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,6 @@ export const isAuthenticated = rule({ cache: "contextual" })((
return "domainAccount" in ctx && !!ctx.domainAccount
})

export const isAuthenticatedForMutation = rule({ cache: "contextual" })((
parent,
args,
ctx: GraphQLPublicContext,
) => {
return "domainAccount" in ctx && !!ctx.domainAccount && !ctx.readOnly
})

const setGqlContext = async (
req: Request,
res: Response,
Expand Down Expand Up @@ -112,7 +104,7 @@ export async function startApolloServerForCoreSchema() {
...mutationFields.authed.atAccountLevel,
...mutationFields.authed.atWalletLevel,
})) {
authedMutationFields[key] = isAuthenticatedForMutation
authedMutationFields[key] = isAuthenticated
}

const permissions = shield(
Expand Down
1 change: 0 additions & 1 deletion core/api/src/servers/index.files.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ type GraphQLPublicContext = {
loaders: Loaders
ip: IpAddress | undefined
sessionId: SessionId | undefined
readOnly: boolean
}

type GraphQLPublicContextAuth = GraphQLPublicContext & {
Expand Down
60 changes: 29 additions & 31 deletions core/api/src/servers/middlewares/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,79 +3,77 @@ import { GraphQLResolveInfo, GraphQLFieldResolver } from "graphql"
import ScopesOauth2 from "@/domain/authorization"
import { AuthorizationError } from "@/domain/errors"
import { mapError } from "@/graphql/error-map"
import { mutationFields } from "@/graphql/public"
import { mutationFields, queryFields } from "@/graphql/public"

const readTransactionsAuthorize = async (
const readAuthorize = async (
resolve: GraphQLFieldResolver<unknown, GraphQLPublicContextAuth>,
parent: unknown,
args: unknown,
context: GraphQLPublicContextAuth,
info: GraphQLResolveInfo,
) => {
const scope = context.scope
const appId = context.appId

// not a delegated token
if (appId === undefined || appId === "") {
return resolve(parent, args, context, info)
}

console.log("SCOPE ", scope)
// not a token with scope
if (scope === undefined || scope.length === 0) {
return mapError(
new AuthorizationError("appId is defined but scope is undefined or empty"),
)
return resolve(parent, args, context, info)
}

if (scope.find((s) => s === ScopesOauth2.TransactionsRead) !== undefined) {
if (scope.find((s) => s === ScopesOauth2.Read) !== undefined) {
return resolve(parent, args, context, info)
}

return mapError(new AuthorizationError("not authorized to read transactions"))
return mapError(new AuthorizationError("not authorized to read data"))
}

const paymentSendAuthorize = async (
const writeAuthorize = async (
resolve: GraphQLFieldResolver<unknown, GraphQLPublicContextAuth>,
parent: unknown,
args: unknown,
context: GraphQLPublicContextAuth,
info: GraphQLResolveInfo,
) => {
const scope = context.scope
const appId = context.appId

// not a delegated token
if (appId === undefined || appId === "") {
// not a token with scope
if (scope === undefined || scope.length === 0) {
return resolve(parent, args, context, info)
}

if (scope === undefined) {
return mapError(new AuthorizationError("appId is defined but scope is undefined"))
}

if (scope.find((s) => s === ScopesOauth2.PaymentsSend) !== undefined) {
if (scope.find((s) => s === ScopesOauth2.Write) !== undefined) {
return resolve(parent, args, context, info)
}

return mapError(new AuthorizationError("not authorized to send payments"))
return mapError(new AuthorizationError("not authorized to execute mutations"))
}

// Placed here because 'GraphQLFieldResolver' not working from .d.ts file
type ValidateWalletIdFn = (
type ValidateFn = (
resolve: GraphQLFieldResolver<unknown, GraphQLPublicContextAuth>,
parent: unknown,
args: unknown,
context: GraphQLPublicContextAuth,
info: GraphQLResolveInfo,
) => Promise<unknown>

const walletIdMutationFields: { [key: string]: ValidateWalletIdFn } = {}
for (const key of Object.keys(mutationFields.authed.atWalletLevel)) {
walletIdMutationFields[key] = paymentSendAuthorize
const authedQueryFields: { [key: string]: ValidateFn } = {}
for (const key of Object.keys({
...queryFields.authed.atAccountLevel,
...queryFields.authed.atWalletLevel,
})) {
authedQueryFields[key] = readAuthorize
}

const authedMutationFields: { [key: string]: ValidateFn } = {}
for (const key of Object.keys({
...mutationFields.authed.atAccountLevel,
...mutationFields.authed.atWalletLevel,
})) {
authedMutationFields[key] = writeAuthorize
}

export const scopeMiddleware = {
Query: {
me: readTransactionsAuthorize,
},
Mutation: walletIdMutationFields,
Query: authedQueryFields,
Mutation: authedMutationFields,
}
4 changes: 1 addition & 3 deletions core/api/src/servers/middlewares/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@ export const sessionPublicContext = async ({

const sessionId = tokenPayload?.session_id
const expiresAt = tokenPayload?.expires_at
const scope = tokenPayload?.scope?.split(" ") ?? []
const scope = (tokenPayload?.scope?.split(" ") ?? []).filter((element: string) => element !== '');
const sub = tokenPayload?.sub
const appId = tokenPayload?.client_id
const readOnly = tokenPayload?.read_only ?? false

// note: value should match (ie: "anon") if not an accountId
// settings from dev/ory/oathkeeper.yml/authenticator/anonymous/config/subjet
Expand Down Expand Up @@ -86,6 +85,5 @@ export const sessionPublicContext = async ({
sessionId,
scope,
appId,
readOnly,
}
}
2 changes: 1 addition & 1 deletion dev/config/ory/oathkeeper_rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
mutators:
- handler: id_token
config: #! TODO: add aud: {"aud": ["https://api/graphql"] }
claims: '{"sub": "{{ print .Subject }}", "session_id": "{{ print .Extra.id }}", "expires_at": "{{ print .Extra.expires_at }}", "scope": "{{ print .Extra.scope }}", "client_id": "{{ print .Extra.client_id }}", "read_only": "{{ print .Extra.read_only }}" }'
claims: '{"sub": "{{ print .Subject }}", "session_id": "{{ print .Extra.id }}", "expires_at": "{{ print .Extra.expires_at }}", "scope": "{{ print .Extra.scope }}", "client_id": "{{ print .Extra.client_id }}"}'

- id: admin-backend
upstream:
Expand Down

0 comments on commit 2453428

Please sign in to comment.