Skip to content

Commit

Permalink
feat(server): ability for apps to create new app tokens (#1942)
Browse files Browse the repository at this point in the history
* /graphql endpoint fix

* app token creation seems to be done

* added tests

* more tests

* cleaned up TS annotations

* CR cleanup

* TS type fixes

* test fixes
  • Loading branch information
fabis94 authored Jan 9, 2024
1 parent b70022a commit 5cd5733
Show file tree
Hide file tree
Showing 18 changed files with 555 additions and 195 deletions.
2 changes: 2 additions & 0 deletions packages/frontend-2/server/routes/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useApiOrigin } from '~/composables/env'

/**
* Taken from apollo-server-core source code. Loads the Apollo Studio sandbox at /graphql.
* Won't work in production, because in production the backend /graphql route takes precedence.
Expand Down
19 changes: 17 additions & 2 deletions packages/server/assets/core/typedefs/apitoken.graphql
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
extend type Query {
"""
If user is authenticated using an app token, this will describe the app
"""
authenticatedAsApp: ServerAppListItem @hasServerRole(role: SERVER_USER)
}

extend type User {
"""
Returns a list of your personal api tokens.
"""
apiTokens: [ApiToken]
apiTokens: [ApiToken!]!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "tokens:read")
}
Expand Down Expand Up @@ -30,10 +37,18 @@ extend type Mutation {
apiTokenCreate(token: ApiTokenCreateInput!): String!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "tokens:write")

"""
Revokes (deletes) an personal api token.
Revokes (deletes) an personal api token/app token.
"""
apiTokenRevoke(token: String!): Boolean!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "tokens:write")

"""
Create an app token. Only apps can create app tokens and they don't show up under personal access tokens.
"""
appTokenCreate(token: ApiTokenCreateInput!): String!
@hasServerRole(role: SERVER_USER)
@hasScope(scope: "tokens:write")
}
21 changes: 5 additions & 16 deletions packages/server/modules/auth/services/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ const bcrypt = require('bcrypt')
const crs = require('crypto-random-string')
const knex = require(`@/db/knex`)

const { createToken, createBareToken } = require(`@/modules/core/services/tokens`)
const { createBareToken, createAppToken } = require(`@/modules/core/services/tokens`)
const { logger } = require('@/logging/logging')
const Users = () => knex('users')
const ApiTokens = () => knex('api_tokens')
const ServerApps = () => knex('server_apps')
const ServerAppsScopes = () => knex('server_apps_scopes')
const ServerAppsTokens = () => knex('user_server_app_tokens')
const Scopes = () => knex('scopes')

const AuthorizationCodes = () => knex('authorization_codes')
Expand Down Expand Up @@ -227,15 +226,10 @@ module.exports = {

const appScopes = scopes.map((s) => s.scopeName)

const { token: appToken } = await createToken({
const appToken = await createAppToken({
userId: code.userId,
name: `${app.name}-token`,
/* lifespan: 1.21e+9, */ scopes: appScopes
})

await ServerAppsTokens().insert({
userId: code.userId,
tokenId: appToken.slice(0, 10),
scopes: appScopes,
appId
})

Expand Down Expand Up @@ -282,15 +276,10 @@ module.exports = {
if (app.secret !== appSecret) throw new Error('Invalid request')

// Create the new token
const { token: appToken } = await createToken({
const appToken = await createAppToken({
userId: refreshTokenDb.userId,
name: `${app.name}-token`,
scopes: app.scopes.map((s) => s.name)
})

await ServerAppsTokens().insert({
userId: refreshTokenDb.userId,
tokenId: appToken.slice(0, 10),
scopes: app.scopes.map((s) => s.name),
appId
})

Expand Down
27 changes: 27 additions & 0 deletions packages/server/modules/core/dbSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,19 @@ export const ApiTokens = buildTableHelper('api_tokens', [
'lastUsed'
])

export const PersonalApiTokens = buildTableHelper('personal_api_tokens', [
'tokenId',
'userId'
])

export const UserServerAppTokens = buildTableHelper('user_server_app_tokens', [
'appId',
'userId',
'tokenId'
])

export const TokenScopes = buildTableHelper('token_scopes', ['tokenId', 'scopeName'])

export const EmailVerifications = buildTableHelper('email_verifications', [
'id',
'email',
Expand Down Expand Up @@ -490,6 +503,20 @@ export const ServerAppsScopes = buildTableHelper('server_apps_scopes', [
'scopeName'
])

export const ServerApps = buildTableHelper('server_apps', [
'id',
'secret',
'name',
'description',
'termsAndConditionsLink',
'logo',
'public',
'trustByDefault',
'authorId',
'createdAt',
'redirectUrl'
])

export const Scopes = buildTableHelper('scopes', ['name', 'description', 'public'])

export { knex }
17 changes: 14 additions & 3 deletions packages/server/modules/core/graph/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,14 +923,16 @@ export type Mutation = {
adminDeleteUser: Scalars['Boolean'];
/** Creates an personal api token. */
apiTokenCreate: Scalars['String'];
/** Revokes (deletes) an personal api token. */
/** Revokes (deletes) an personal api token/app token. */
apiTokenRevoke: Scalars['Boolean'];
/** Register a new third party application. */
appCreate: Scalars['String'];
/** Deletes a thirty party application. */
appDelete: Scalars['Boolean'];
/** Revokes (de-authorizes) an application that you have previously authorized. */
appRevokeAccess?: Maybe<Scalars['Boolean']>;
/** Create an app token. Only apps can create app tokens and they don't show up under personal access tokens. */
appTokenCreate: Scalars['String'];
/** Update an existing third party application. **Note: This will invalidate all existing tokens, refresh tokens and access codes and will require existing users to re-authorize it.** */
appUpdate: Scalars['Boolean'];
automationMutations: AutomationMutations;
Expand Down Expand Up @@ -1071,6 +1073,11 @@ export type MutationAppRevokeAccessArgs = {
};


export type MutationAppTokenCreateArgs = {
token: ApiTokenCreateInput;
};


export type MutationAppUpdateArgs = {
app: AppUpdateInput;
};
Expand Down Expand Up @@ -1821,6 +1828,8 @@ export type Query = {
app?: Maybe<ServerApp>;
/** Returns all the publicly available apps on this server. */
apps?: Maybe<Array<Maybe<ServerAppListItem>>>;
/** If user is authenticated using an app token, this will describe the app */
authenticatedAsApp?: Maybe<ServerAppListItem>;
comment?: Maybe<Comment>;
/**
* This query can be used in the following ways:
Expand Down Expand Up @@ -2575,7 +2584,7 @@ export type User = {
/** All the recent activity from this user in chronological order */
activity?: Maybe<ActivityCollection>;
/** Returns a list of your personal api tokens. */
apiTokens?: Maybe<Array<Maybe<ApiToken>>>;
apiTokens: Array<ApiToken>;
/** Returns the apps you have authorized. */
authorizedApps?: Maybe<Array<Maybe<ServerAppListItem>>>;
avatar?: Maybe<Scalars['String']>;
Expand Down Expand Up @@ -3727,6 +3736,7 @@ export type MutationResolvers<ContextType = GraphQLContext, ParentType extends R
appCreate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationAppCreateArgs, 'app'>>;
appDelete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationAppDeleteArgs, 'appId'>>;
appRevokeAccess?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType, RequireFields<MutationAppRevokeAccessArgs, 'appId'>>;
appTokenCreate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationAppTokenCreateArgs, 'token'>>;
appUpdate?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationAppUpdateArgs, 'app'>>;
automationMutations?: Resolver<ResolversTypes['AutomationMutations'], ParentType, ContextType>;
branchCreate?: Resolver<ResolversTypes['String'], ParentType, ContextType, RequireFields<MutationBranchCreateArgs, 'branch'>>;
Expand Down Expand Up @@ -3964,6 +3974,7 @@ export type QueryResolvers<ContextType = GraphQLContext, ParentType extends Reso
adminUsers?: Resolver<Maybe<ResolversTypes['AdminUsersListCollection']>, ParentType, ContextType, RequireFields<QueryAdminUsersArgs, 'limit' | 'offset' | 'query'>>;
app?: Resolver<Maybe<ResolversTypes['ServerApp']>, ParentType, ContextType, RequireFields<QueryAppArgs, 'id'>>;
apps?: Resolver<Maybe<Array<Maybe<ResolversTypes['ServerAppListItem']>>>, ParentType, ContextType>;
authenticatedAsApp?: Resolver<Maybe<ResolversTypes['ServerAppListItem']>, ParentType, ContextType>;
comment?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType, RequireFields<QueryCommentArgs, 'id' | 'streamId'>>;
comments?: Resolver<Maybe<ResolversTypes['CommentCollection']>, ParentType, ContextType, RequireFields<QueryCommentsArgs, 'archived' | 'limit' | 'streamId'>>;
discoverableStreams?: Resolver<Maybe<ResolversTypes['StreamCollection']>, ParentType, ContextType, RequireFields<QueryDiscoverableStreamsArgs, 'limit'>>;
Expand Down Expand Up @@ -4185,7 +4196,7 @@ export type TestItemResolvers<ContextType = GraphQLContext, ParentType extends R

export type UserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User']> = {
activity?: Resolver<Maybe<ResolversTypes['ActivityCollection']>, ParentType, ContextType, RequireFields<UserActivityArgs, 'limit'>>;
apiTokens?: Resolver<Maybe<Array<Maybe<ResolversTypes['ApiToken']>>>, ParentType, ContextType>;
apiTokens?: Resolver<Array<ResolversTypes['ApiToken']>, ParentType, ContextType>;
authorizedApps?: Resolver<Maybe<Array<Maybe<ResolversTypes['ServerAppListItem']>>>, ParentType, ContextType>;
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
bio?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
Expand Down
5 changes: 2 additions & 3 deletions packages/server/modules/core/graph/resolvers/apitoken.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@ const resolvers = {
throw new ForbiddenError('You can only view your own tokens')

const tokens = await getUserTokens(context.userId)
return tokens
return tokens || []
}
},
Mutation: {
async apiTokenCreate(parent, args, context) {
canCreatePAT({
userScopes: context.scopes || [],
tokenScopes: args.token.scopes,
strict: true
tokenScopes: args.token.scopes
})

return await createPersonalAccessToken(
Expand Down
36 changes: 36 additions & 0 deletions packages/server/modules/core/graph/resolvers/appTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { canCreateAppToken } from '@/modules/core/helpers/token'
import { getTokenAppInfo } from '@/modules/core/repositories/tokens'
import { createAppToken } from '@/modules/core/services/tokens'

export = {
Query: {
async authenticatedAsApp(_parent, _args, ctx) {
const { appId, token } = ctx
if (!appId || !token) return null

return (await getTokenAppInfo({ appId, token })) || null
}
},
Mutation: {
async appTokenCreate(_parent, args, ctx) {
const appId = ctx.appId || '' // validation that this is a valid app id is done in canCreateAppToken

canCreateAppToken({
userScopes: ctx.scopes || [],
tokenScopes: args.token.scopes,
// both app ids are the same in this scenario, since there's no way to specify a different token app id
userAppId: appId,
tokenAppId: appId
})

const token = await createAppToken({
...args.token,
userId: ctx.userId!,
appId,
lifespan: args.token.lifespan || undefined
})
return token
}
}
} as Resolvers
24 changes: 18 additions & 6 deletions packages/server/modules/core/helpers/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@ import { Scopes } from '@speckle/shared'
export const canCreateToken = (params: {
userScopes: string[]
tokenScopes: string[]
strict?: boolean
}) => {
const { userScopes, tokenScopes, strict } = params
const { userScopes, tokenScopes } = params
const hasAllScopes = tokenScopes.every((scope) => userScopes.includes(scope))
if (!hasAllScopes) {
if (!strict) return false
throw new TokenCreateError(
"You can't create a token with scopes that you don't have"
)
Expand All @@ -21,15 +19,29 @@ export const canCreateToken = (params: {
export const canCreatePAT = (params: {
userScopes: string[]
tokenScopes: string[]
strict?: boolean
}) => {
const { tokenScopes, strict } = params
const { tokenScopes } = params
if (tokenScopes.includes(Scopes.Tokens.Write)) {
if (!strict) return false
throw new TokenCreateError(
"You can't create a personal access token with the tokens:write scope"
)
}

return canCreateToken(params)
}

export const canCreateAppToken = (params: {
userScopes: string[]
tokenScopes: string[]
userAppId: string
tokenAppId: string
}) => {
const { userAppId, tokenAppId } = params
if (userAppId !== tokenAppId || !tokenAppId?.length || !userAppId?.length) {
throw new TokenCreateError(
'An app token can only create a new token for the same app'
)
}

return canCreateToken(params)
}
23 changes: 23 additions & 0 deletions packages/server/modules/core/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,29 @@ export type ValidTokenResult = {
scopes: string[]
userId: string
role: ServerRoles
/**
* Set, if the token is an app token
*/
appId: Nullable<string>
}

export type TokenValidationResult = InvalidTokenResult | ValidTokenResult

export type TokenScopesRecord = {
tokenId: string
scopeName: string
}

export type ServerAppRecord = {
id: string
secret: Nullable<string>
name: string
description: Nullable<string>
termsAndConditionsLink: Nullable<string>
logo: Nullable<string>
public: boolean
trustByDefault: boolean
authorId: string
createdAt: Date
redirectUrl: string
}
27 changes: 27 additions & 0 deletions packages/server/modules/core/repositories/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiTokens, ServerApps, UserServerAppTokens } from '@/modules/core/dbSchema'
import { ServerAppRecord } from '@/modules/core/helpers/types'

export async function getTokenAppInfo(params: { token: string; appId?: string }) {
const { token, appId } = params
const tokenId = token.slice(0, 10)

const q = ApiTokens.knex()
.select<ServerAppRecord[]>(ServerApps.cols)
.where({
[ApiTokens.col.id]: tokenId,
...(appId
? {
[UserServerAppTokens.col.appId]: appId
}
: {})
})
.innerJoin(
UserServerAppTokens.name,
ApiTokens.col.id,
UserServerAppTokens.col.tokenId
)
.innerJoin(ServerApps.name, ServerApps.col.id, UserServerAppTokens.col.appId)
.first()

return await q
}
Loading

0 comments on commit 5cd5733

Please sign in to comment.