Skip to content

Commit

Permalink
feat: Param decorator to parse our app user agent strings (#16472)
Browse files Browse the repository at this point in the history
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
eirikurn and kodiakhq[bot] authored Oct 21, 2024
1 parent cc13e8e commit 4e91db4
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 0 deletions.
1 change: 1 addition & 0 deletions libs/nest/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './lib/decorators/nationalId.decorator'
export * from './lib/decorators/ParsedUserAgent.decorator'
export * from './lib/validators/isPersonNationalId.decorator'
export * from './lib/validators/isNationalId.decorator'
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { parseUserAgent } from './ParsedUserAgent.decorator'

describe('parseUserAgent Function', () => {
it('should parse new iOS app user agent', () => {
// Arrange
const userAgentString = 'IslandIsApp (1.0.0) Build/321 (ios/9.0.0)'

// Act
const result = parseUserAgent(userAgentString)

// Assert
expect(result).toEqual({
ua: userAgentString,
os: { name: 'iOS', version: '9.0.0' },
app: { name: 'IslandIsApp', version: '1.0.0', build: 321 },
})
})

it('should parse new Android app user agent', () => {
// Arrange
const userAgentString = 'IslandIsApp (2.1.0) Build/123 (android/11.0.0)'

// Act
const result = parseUserAgent(userAgentString)

// Assert
expect(result).toEqual({
ua: userAgentString,
os: { name: 'Android', version: '11.0.0' },
app: { name: 'IslandIsApp', version: '2.1.0', build: 123 },
})
})

it('should parse old iOS app user agent', () => {
// Arrange
const userAgentString = 'IslandApp/144 CFNetwork/1498.700.2 Darwin/23.6.0'

// Act
const result = parseUserAgent(userAgentString)

// Assert
expect(result).toEqual({
ua: userAgentString,
os: { name: 'iOS' },
app: { name: 'IslandIsApp' },
})
})

it('should parse old Android app user agent', () => {
// Arrange
const userAgentString = 'okhttp/4.9.2'

// Act
const result = parseUserAgent(userAgentString)

// Assert
expect(result).toEqual({
ua: userAgentString,
os: { name: 'Android' },
app: { name: 'IslandIsApp' },
})
})

it('should return empty app and os for unknown user agent', () => {
// Arrange
const userAgentString = 'UnknownUserAgent/1.0'

// Act
const result = parseUserAgent(userAgentString)

// Assert
expect(result).toEqual({
ua: userAgentString,
os: {},
app: {},
})
})
})
92 changes: 92 additions & 0 deletions libs/nest/core/src/lib/decorators/ParsedUserAgent.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { getRequest } from '@island.is/auth-nest-tools'
import { createParamDecorator, ExecutionContext } from '@nestjs/common'

// Old iOS app user agents:
// - IslandApp/144 CFNetwork/1498.700.2 Darwin/23.6.0

// Old Android app user agents. We associate it with IslandIsApp, assuming
// there are no other React Native android apps calling our API.
// - okhttp/4.9.2

// New app user agents
// - IslandIsApp (1.0.0}) Build/321 (ios/9.0.0)
// - IslandIsApp (1.0.0}) Build/321 (android/9.0.0)

export interface UserAgent {
ua: string
os: {
name?: 'iOS' | 'Android'
version?: string
}
app: {
name?: 'IslandIsApp'
version?: string
build?: number
}
}

/**
* Parses the user agent string and returns an object with the user agent, os and app information.
*
* Only supports parsing IslandIsApp user agents to start with but the interface is
* future compatible if we want to parse more user agents with ua-parser-js.
*/
export const parseUserAgent = (userAgentString: string): UserAgent => {
const userAgent: UserAgent = {
ua: userAgentString,
os: {},
app: {},
}

// Match new app user agents
const newAppRegex =
/IslandIsApp \(([^)]+)\) Build\/(\d+) \((ios|android)\/([^)]+)\)/
const newAppMatch = userAgentString.match(newAppRegex)

if (newAppMatch) {
userAgent.app.name = 'IslandIsApp'
userAgent.app.version = newAppMatch[1]
userAgent.app.build = parseInt(newAppMatch[2], 10)
userAgent.os.name = newAppMatch[3] === 'ios' ? 'iOS' : 'Android'
userAgent.os.version = newAppMatch[4]
return userAgent
}

// Match old iOS app user agents
const oldIosRegex = /IslandApp\/\d+ CFNetwork\/[^\s]+ Darwin\/[^\s]+/
const oldIosMatch = userAgentString.match(oldIosRegex)

if (oldIosMatch) {
userAgent.app.name = 'IslandIsApp'
userAgent.os.name = 'iOS'
return userAgent
}

// Match old Android app user agents
const oldAndroidRegex = /okhttp\/[^\s]+/
const oldAndroidMatch = userAgentString.match(oldAndroidRegex)

if (oldAndroidMatch) {
userAgent.app.name = 'IslandIsApp'
userAgent.os.name = 'Android'
return userAgent
}

// Default return, if no patterns matched
return userAgent
}

/**
* Decorator that parses the user agent string from the request headers and returns an object with the user agent, os
* and app information.
*
* Only supports parsing IslandIsApp user agents for now.
*/
export const ParsedUserAgent = createParamDecorator(
(_: unknown, context: ExecutionContext): UserAgent => {
const request = getRequest(context)
const userAgentString = request.headers['user-agent'] || ''

return parseUserAgent(userAgentString)
},
)

0 comments on commit 4e91db4

Please sign in to comment.